fix: batch 4 — connection pooling, graceful shutdown, email verification UX

- Prisma: connection_limit=10, pool_timeout=30 on DATABASE_URL in both compose files
- Graceful shutdown: SIGTERM/SIGINT forwarded to Node process in docker-entrypoint.sh
- testEmailConnection: replaced real email send with transporter.verify(), simplified UI to single button
- NotificationLog.userId index: confirmed already present, no change needed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 18:16:29 +01:00
parent 6f55fdf81f
commit a68ec3fb45
6 changed files with 48 additions and 73 deletions

View File

@@ -68,7 +68,7 @@ services:
env_file: env_file:
- ../.env - ../.env
environment: environment:
- DATABASE_URL=postgresql://${POSTGRES_USER:-mopc}:${POSTGRES_PASSWORD:-devpassword}@postgres:5432/${POSTGRES_DB:-mopc} - DATABASE_URL=postgresql://${POSTGRES_USER:-mopc}:${POSTGRES_PASSWORD:-devpassword}@postgres:5432/${POSTGRES_DB:-mopc}?connection_limit=10&pool_timeout=30
- NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:3000} - NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:3000}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-dev-secret-key-for-local-development-only} - NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-dev-secret-key-for-local-development-only}
- AUTH_SECRET=${AUTH_SECRET:-dev-secret-key-for-local-development-only} - AUTH_SECRET=${AUTH_SECRET:-dev-secret-key-for-local-development-only}

View File

@@ -23,7 +23,7 @@ services:
- .env - .env
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- DATABASE_URL=postgresql://mopc:${DB_PASSWORD}@postgres:5432/mopc - DATABASE_URL=postgresql://mopc:${DB_PASSWORD}@postgres:5432/mopc?connection_limit=10&pool_timeout=30
- NEXTAUTH_URL=${NEXTAUTH_URL} - NEXTAUTH_URL=${NEXTAUTH_URL}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET} - NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- AUTH_SECRET=${NEXTAUTH_SECRET} - AUTH_SECRET=${NEXTAUTH_SECRET}

View File

@@ -59,4 +59,18 @@ else
fi fi
echo "==> Starting application..." echo "==> Starting application..."
exec node server.js
# Graceful shutdown: forward SIGTERM/SIGINT to the Node process
# so in-flight requests can complete before the container exits.
shutdown() {
echo "==> Received shutdown signal, stopping gracefully..."
kill -TERM "$NODE_PID" 2>/dev/null
wait "$NODE_PID"
exit $?
}
trap shutdown TERM INT
node server.js &
NODE_PID=$!
wait "$NODE_PID"

View File

@@ -11,6 +11,10 @@ generator client {
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
// connection_limit and pool_timeout are set via query params in DATABASE_URL:
// ?connection_limit=10&pool_timeout=30
// Defaults: connection_limit = num_cpus * 2 + 1, pool_timeout = 10s.
// Override in .env for production to prevent connection exhaustion.
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }

View File

@@ -1,6 +1,5 @@
'use client' 'use client'
import { useState } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
@@ -18,15 +17,6 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from '@/components/ui/form' } from '@/components/ui/form'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
const formSchema = z.object({ const formSchema = z.object({
smtp_host: z.string().min(1, 'SMTP host is required'), smtp_host: z.string().min(1, 'SMTP host is required'),
@@ -51,8 +41,6 @@ interface EmailSettingsFormProps {
} }
export function EmailSettingsForm({ settings }: EmailSettingsFormProps) { export function EmailSettingsForm({ settings }: EmailSettingsFormProps) {
const [testDialogOpen, setTestDialogOpen] = useState(false)
const [testEmail, setTestEmail] = useState('')
const utils = trpc.useUtils() const utils = trpc.useUtils()
const form = useForm<FormValues>({ const form = useForm<FormValues>({
@@ -77,17 +65,16 @@ export function EmailSettingsForm({ settings }: EmailSettingsFormProps) {
}, },
}) })
const sendTestEmail = trpc.settings.testEmailConnection.useMutation({ const verifyConnection = trpc.settings.testEmailConnection.useMutation({
onSuccess: (result) => { onSuccess: (result) => {
setTestDialogOpen(false)
if (result.success) { if (result.success) {
toast.success('Test email sent successfully') toast.success('SMTP connection verified successfully')
} else { } else {
toast.error(`Failed to send test email: ${result.error}`) toast.error(`SMTP verification failed: ${result.error}`)
} }
}, },
onError: (error) => { onError: (error) => {
toast.error(`Test failed: ${error.message}`) toast.error(`Verification failed: ${error.message}`)
}, },
}) })
@@ -107,12 +94,8 @@ export function EmailSettingsForm({ settings }: EmailSettingsFormProps) {
updateSettings.mutate({ settings: settingsToUpdate }) updateSettings.mutate({ settings: settingsToUpdate })
} }
const handleSendTest = () => { const handleVerifyConnection = () => {
if (!testEmail) { verifyConnection.mutate()
toast.error('Please enter an email address')
return
}
sendTestEmail.mutate({ testEmail })
} }
return ( return (
@@ -243,49 +226,24 @@ export function EmailSettingsForm({ settings }: EmailSettingsFormProps) {
)} )}
</Button> </Button>
<Dialog open={testDialogOpen} onOpenChange={setTestDialogOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline">
<Send className="mr-2 h-4 w-4" />
Send Test Email
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Send Test Email</DialogTitle>
<DialogDescription>
Enter an email address to receive a test email
</DialogDescription>
</DialogHeader>
<Input
type="email"
placeholder="test@example.com"
value={testEmail}
onChange={(e) => setTestEmail(e.target.value)}
/>
<DialogFooter>
<Button <Button
type="button"
variant="outline" variant="outline"
onClick={() => setTestDialogOpen(false)} onClick={handleVerifyConnection}
disabled={verifyConnection.isPending}
> >
Cancel {verifyConnection.isPending ? (
</Button>
<Button
onClick={handleSendTest}
disabled={sendTestEmail.isPending}
>
{sendTestEmail.isPending ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sending... Verifying...
</> </>
) : ( ) : (
'Send Test' <>
<Send className="mr-2 h-4 w-4" />
Verify Connection
</>
)} )}
</Button> </Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
</form> </form>
</Form> </Form>

View File

@@ -322,12 +322,11 @@ export const settingsRouter = router({
* Test email connection * Test email connection
*/ */
testEmailConnection: superAdminProcedure testEmailConnection: superAdminProcedure
.input(z.object({ testEmail: z.string().email() })) .mutation(async () => {
.mutation(async ({ ctx, input }) => {
try { try {
const { sendTestEmail } = await import('@/lib/email') const { verifyEmailConnection } = await import('@/lib/email')
const success = await sendTestEmail(input.testEmail) const success = await verifyEmailConnection()
return { success, error: success ? null : 'Failed to send test email' } return { success, error: success ? null : 'SMTP connection verification failed' }
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error' const message = error instanceof Error ? error.message : 'Unknown error'
return { success: false, error: `Email configuration error: ${message}` } return { success: false, error: `Email configuration error: ${message}` }