diff --git a/Dockerfile b/Dockerfile
index ffe5836..f0986d6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -23,30 +23,40 @@ FROM node:18-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
+ENV PORT=3000
+ENV HOSTNAME="0.0.0.0"
# Install OpenSSL for Prisma and curl for healthcheck
RUN apt-get update && apt-get install -y openssl curl && rm -rf /var/lib/apt/lists/*
-# Create non-root user
+# Create non-root user with a writable home directory
RUN addgroup --system --gid 1001 nodejs
-RUN adduser --system --uid 1001 nextjs
+RUN adduser --system --uid 1001 --home /home/nextjs nextjs
-# Copy built output and dependencies
-COPY --from=builder /app/package*.json ./
-COPY --from=builder /app/node_modules ./node_modules
-COPY --from=builder /app/.next ./.next
-COPY --from=builder /app/public ./public
-COPY --from=builder /app/prisma ./prisma
-COPY --from=builder /app/next.config.js ./
-
-# Create logs directory
+# Set correct permissions
RUN mkdir -p /app/logs && chown -R nextjs:nodejs /app
+# Copy built output
+COPY --from=builder /app/public ./public
+COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
+COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+COPY --from=builder /app/prisma ./prisma
+
+# Copy Prisma CLI and generated client from builder so npx doesn't re-download
+COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
+COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
+COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
+
+# Copy entrypoint script
+COPY scripts/docker-entrypoint.sh /usr/local/bin/
+RUN chmod +x /usr/local/bin/docker-entrypoint.sh
+
USER nextjs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
- CMD curl -f http://localhost:3001/api/auth/session || exit 1
+ CMD curl -f http://localhost:3000/api/auth/session || exit 1
-CMD ["npm", "run", "start"]
+ENTRYPOINT ["docker-entrypoint.sh"]
+CMD ["node", "server.js"]
diff --git a/docker-compose.yml b/docker-compose.yml
index 6df5a6c..6b0b3d4 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -2,6 +2,7 @@ version: '3.8'
services:
app:
+ build: .
image: my-weekly-todo:latest
ports:
- "13000:3000"
@@ -10,7 +11,7 @@ services:
depends_on:
- db
volumes:
- - app_logs:/app/logs
+ - ./logs:/app/logs
restart: unless-stopped
db:
diff --git a/next.config.js b/next.config.js
index a96ff51..85d21a7 100644
--- a/next.config.js
+++ b/next.config.js
@@ -2,6 +2,7 @@
const { version } = require('./package.json');
const nextConfig = {
+ output: 'standalone',
env: {
NEXT_PUBLIC_APP_VERSION: version,
},
diff --git a/package.json b/package.json
index a2b2b61..f7efb65 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "my-weekly-todo-list",
- "version": "1.5.0",
+ "version": "1.6.0",
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js",
"scripts": {
diff --git a/prisma/migrations/20260224_add_missing_columns/migration.sql b/prisma/migrations/20260224_add_missing_columns/migration.sql
new file mode 100644
index 0000000..a4a76d3
--- /dev/null
+++ b/prisma/migrations/20260224_add_missing_columns/migration.sql
@@ -0,0 +1,117 @@
+-- AlterTable: Add missing User settings columns
+ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "goalFallbackType" TEXT NOT NULL DEFAULT 'quote';
+ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "goalDefaultSentence" TEXT NOT NULL DEFAULT 'goal of the week';
+ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "dateLayout" TEXT NOT NULL DEFAULT 'right';
+ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "dateAlignment" TEXT NOT NULL DEFAULT 'center';
+ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "hourLabelFormat" TEXT NOT NULL DEFAULT 'short';
+ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "showSubHourSlots" BOOLEAN NOT NULL DEFAULT true;
+ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "allDayPosition" TEXT NOT NULL DEFAULT 'below';
+ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "cwColor" TEXT DEFAULT '#333333';
+ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "cwFontFamily" TEXT DEFAULT 'Inter';
+ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "cwFontSize" TEXT DEFAULT '1.125rem';
+ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "cwFontWeight" TEXT DEFAULT '700';
+ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "yearColor" TEXT DEFAULT '#333333';
+ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "yearFontFamily" TEXT DEFAULT 'Inter';
+ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "yearFontSize" TEXT DEFAULT '1.125rem';
+ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "yearFontWeight" TEXT DEFAULT '700';
+
+-- AlterTable: Add missing SomedayList columns
+ALTER TABLE "SomedayList" ADD COLUMN IF NOT EXISTS "externalId" TEXT;
+ALTER TABLE "SomedayList" ADD COLUMN IF NOT EXISTS "externalProvider" TEXT;
+ALTER TABLE "SomedayList" ADD COLUMN IF NOT EXISTS "lastSyncedAt" TIMESTAMP(3);
+
+-- AlterTable: Add missing Task columns
+ALTER TABLE "Task" ADD COLUMN IF NOT EXISTS "parentTaskId" TEXT;
+ALTER TABLE "Task" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
+
+-- CreateTable: WeeklyGoal
+CREATE TABLE IF NOT EXISTS "WeeklyGoal" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "weekStart" TIMESTAMP(3) NOT NULL,
+ "text" TEXT NOT NULL DEFAULT 'your goal of this week',
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "WeeklyGoal_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable: CachedCalendarEvent
+CREATE TABLE IF NOT EXISTS "CachedCalendarEvent" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "externalId" TEXT NOT NULL,
+ "connectionId" TEXT NOT NULL,
+ "provider" TEXT NOT NULL,
+ "calendarId" TEXT NOT NULL,
+ "calendarTitle" TEXT NOT NULL,
+ "calendarColor" TEXT,
+ "title" TEXT NOT NULL,
+ "description" TEXT,
+ "location" TEXT,
+ "startDateTime" TIMESTAMP(3),
+ "startDate" TEXT,
+ "endDateTime" TIMESTAMP(3),
+ "endDate" TEXT,
+ "weekStart" TIMESTAMP(3) NOT NULL,
+ "syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "CachedCalendarEvent_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndexes for WeeklyGoal
+CREATE UNIQUE INDEX IF NOT EXISTS "WeeklyGoal_userId_weekStart_key" ON "WeeklyGoal"("userId", "weekStart");
+CREATE INDEX IF NOT EXISTS "WeeklyGoal_userId_idx" ON "WeeklyGoal"("userId");
+
+-- CreateIndexes for CachedCalendarEvent
+CREATE UNIQUE INDEX IF NOT EXISTS "CachedCalendarEvent_userId_externalId_provider_key" ON "CachedCalendarEvent"("userId", "externalId", "provider");
+CREATE INDEX IF NOT EXISTS "CachedCalendarEvent_userId_startDateTime_idx" ON "CachedCalendarEvent"("userId", "startDateTime");
+CREATE INDEX IF NOT EXISTS "CachedCalendarEvent_userId_startDate_idx" ON "CachedCalendarEvent"("userId", "startDate");
+CREATE INDEX IF NOT EXISTS "CachedCalendarEvent_connectionId_weekStart_idx" ON "CachedCalendarEvent"("connectionId", "weekStart");
+
+-- CreateIndex for Task
+CREATE INDEX IF NOT EXISTS "Task_parentTaskId_idx" ON "Task"("parentTaskId");
+CREATE INDEX IF NOT EXISTS "Task_userId_deletedAt_idx" ON "Task"("userId", "deletedAt");
+
+-- AddForeignKeys (idempotent)
+DO $$ BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.table_constraints
+ WHERE constraint_name = 'Task_parentTaskId_fkey'
+ ) THEN
+ ALTER TABLE "Task" ADD CONSTRAINT "Task_parentTaskId_fkey" FOREIGN KEY ("parentTaskId") REFERENCES "Task"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ END IF;
+END $$;
+
+DO $$ BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.table_constraints
+ WHERE constraint_name = 'WeeklyGoal_userId_fkey'
+ ) THEN
+ ALTER TABLE "WeeklyGoal" ADD CONSTRAINT "WeeklyGoal_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ END IF;
+END $$;
+
+DO $$ BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.table_constraints
+ WHERE constraint_name = 'CachedCalendarEvent_connectionId_fkey'
+ ) THEN
+ ALTER TABLE "CachedCalendarEvent" ADD CONSTRAINT "CachedCalendarEvent_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "CalendarConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ END IF;
+END $$;
+
+DO $$ BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.table_constraints
+ WHERE constraint_name = 'CachedCalendarEvent_userId_fkey'
+ ) THEN
+ ALTER TABLE "CachedCalendarEvent" ADD CONSTRAINT "CachedCalendarEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ END IF;
+END $$;
+
+-- Fix Task_somedayListId_fkey to use SET NULL on delete (idempotent)
+ALTER TABLE "Task" DROP CONSTRAINT IF EXISTS "Task_somedayListId_fkey";
+ALTER TABLE "Task" ADD CONSTRAINT "Task_somedayListId_fkey" FOREIGN KEY ("somedayListId") REFERENCES "SomedayList"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/prisma/migrations/20260224_add_missing_tables/migration.sql b/prisma/migrations/20260224_add_missing_tables/migration.sql
new file mode 100644
index 0000000..2d367e2
--- /dev/null
+++ b/prisma/migrations/20260224_add_missing_tables/migration.sql
@@ -0,0 +1,100 @@
+-- AlterTable: Add missing SomedayList columns
+ALTER TABLE "SomedayList" ADD COLUMN IF NOT EXISTS "externalId" TEXT;
+ALTER TABLE "SomedayList" ADD COLUMN IF NOT EXISTS "externalProvider" TEXT;
+ALTER TABLE "SomedayList" ADD COLUMN IF NOT EXISTS "lastSyncedAt" TIMESTAMP(3);
+
+-- AlterTable: Add missing Task columns
+ALTER TABLE "Task" ADD COLUMN IF NOT EXISTS "parentTaskId" TEXT;
+ALTER TABLE "Task" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
+
+-- CreateTable: WeeklyGoal
+CREATE TABLE IF NOT EXISTS "WeeklyGoal" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "weekStart" TIMESTAMP(3) NOT NULL,
+ "text" TEXT NOT NULL DEFAULT 'your goal of this week',
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "WeeklyGoal_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable: CachedCalendarEvent
+CREATE TABLE IF NOT EXISTS "CachedCalendarEvent" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "externalId" TEXT NOT NULL,
+ "connectionId" TEXT NOT NULL,
+ "provider" TEXT NOT NULL,
+ "calendarId" TEXT NOT NULL,
+ "calendarTitle" TEXT NOT NULL,
+ "calendarColor" TEXT,
+ "title" TEXT NOT NULL,
+ "description" TEXT,
+ "location" TEXT,
+ "startDateTime" TIMESTAMP(3),
+ "startDate" TEXT,
+ "endDateTime" TIMESTAMP(3),
+ "endDate" TEXT,
+ "weekStart" TIMESTAMP(3) NOT NULL,
+ "syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "CachedCalendarEvent_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndexes for WeeklyGoal
+CREATE UNIQUE INDEX IF NOT EXISTS "WeeklyGoal_userId_weekStart_key" ON "WeeklyGoal"("userId", "weekStart");
+CREATE INDEX IF NOT EXISTS "WeeklyGoal_userId_idx" ON "WeeklyGoal"("userId");
+
+-- CreateIndexes for CachedCalendarEvent
+CREATE UNIQUE INDEX IF NOT EXISTS "CachedCalendarEvent_userId_externalId_provider_key" ON "CachedCalendarEvent"("userId", "externalId", "provider");
+CREATE INDEX IF NOT EXISTS "CachedCalendarEvent_userId_startDateTime_idx" ON "CachedCalendarEvent"("userId", "startDateTime");
+CREATE INDEX IF NOT EXISTS "CachedCalendarEvent_userId_startDate_idx" ON "CachedCalendarEvent"("userId", "startDate");
+CREATE INDEX IF NOT EXISTS "CachedCalendarEvent_connectionId_weekStart_idx" ON "CachedCalendarEvent"("connectionId", "weekStart");
+
+-- CreateIndexes for Task
+CREATE INDEX IF NOT EXISTS "Task_parentTaskId_idx" ON "Task"("parentTaskId");
+CREATE INDEX IF NOT EXISTS "Task_userId_deletedAt_idx" ON "Task"("userId", "deletedAt");
+
+-- AddForeignKeys (idempotent)
+DO $$ BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.table_constraints
+ WHERE constraint_name = 'Task_parentTaskId_fkey'
+ ) THEN
+ ALTER TABLE "Task" ADD CONSTRAINT "Task_parentTaskId_fkey" FOREIGN KEY ("parentTaskId") REFERENCES "Task"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ END IF;
+END $$;
+
+DO $$ BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.table_constraints
+ WHERE constraint_name = 'WeeklyGoal_userId_fkey'
+ ) THEN
+ ALTER TABLE "WeeklyGoal" ADD CONSTRAINT "WeeklyGoal_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ END IF;
+END $$;
+
+DO $$ BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.table_constraints
+ WHERE constraint_name = 'CachedCalendarEvent_connectionId_fkey'
+ ) THEN
+ ALTER TABLE "CachedCalendarEvent" ADD CONSTRAINT "CachedCalendarEvent_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "CalendarConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ END IF;
+END $$;
+
+DO $$ BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.table_constraints
+ WHERE constraint_name = 'CachedCalendarEvent_userId_fkey'
+ ) THEN
+ ALTER TABLE "CachedCalendarEvent" ADD CONSTRAINT "CachedCalendarEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ END IF;
+END $$;
+
+-- Fix Task_somedayListId_fkey to use SET NULL on delete
+ALTER TABLE "Task" DROP CONSTRAINT IF EXISTS "Task_somedayListId_fkey";
+ALTER TABLE "Task" ADD CONSTRAINT "Task_somedayListId_fkey" FOREIGN KEY ("somedayListId") REFERENCES "SomedayList"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh
new file mode 100755
index 0000000..4861b19
--- /dev/null
+++ b/scripts/docker-entrypoint.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+set -e
+
+# Run migrations using the local prisma binary
+echo "Running database migrations..."
+node node_modules/prisma/build/index.js migrate deploy
+
+# Start the application
+echo "Starting application..."
+exec "$@"
diff --git a/src/app/api/calendar/outlook/callback/route.ts b/src/app/api/calendar/outlook/callback/route.ts
index 69d2ede..d2df28d 100644
--- a/src/app/api/calendar/outlook/callback/route.ts
+++ b/src/app/api/calendar/outlook/callback/route.ts
@@ -5,11 +5,13 @@ import { prisma } from '@/lib/prisma';
import { getTokens, getUserCalendars } from '@/lib/outlook-calendar';
export async function GET(request: NextRequest) {
+ const baseUrl = process.env.NEXTAUTH_URL || request.url;
+
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
- return NextResponse.redirect(new URL('/auth/login', request.url));
+ return NextResponse.redirect(new URL('/auth/login', baseUrl));
}
const { searchParams } = new URL(request.url);
@@ -18,11 +20,11 @@ export async function GET(request: NextRequest) {
if (error) {
console.error('Outlook OAuth error:', error);
- return NextResponse.redirect(new URL('/?error=outlook_auth_failed', request.url));
+ return NextResponse.redirect(new URL('/?error=outlook_auth_failed', baseUrl));
}
if (!code) {
- return NextResponse.redirect(new URL('/?error=no_code', request.url));
+ return NextResponse.redirect(new URL('/?error=no_code', baseUrl));
}
// Exchange code for tokens
@@ -39,7 +41,7 @@ export async function GET(request: NextRequest) {
});
if (!user) {
- return NextResponse.redirect(new URL('/auth/login', request.url));
+ return NextResponse.redirect(new URL('/auth/login', baseUrl));
}
// Calculate expiry date
@@ -89,13 +91,13 @@ export async function GET(request: NextRequest) {
// Redirect to a client-side page that re-establishes the session
// Direct redirects from Microsoft OAuth may lose the session cookie (SameSite policy)
- const redirectUrl = new URL('/auth/oauth-complete', request.url);
+ const redirectUrl = new URL('/auth/oauth-complete', baseUrl);
redirectUrl.searchParams.set('provider', 'outlook');
redirectUrl.searchParams.set('status', 'connected');
return NextResponse.redirect(redirectUrl);
} catch (error) {
console.error('Error in Outlook callback:', error);
- const redirectUrl = new URL('/auth/oauth-complete', request.url);
+ const redirectUrl = new URL('/auth/oauth-complete', baseUrl);
redirectUrl.searchParams.set('provider', 'outlook');
redirectUrl.searchParams.set('status', 'error');
redirectUrl.searchParams.set('message', 'outlook_callback_failed');
diff --git a/src/app/api/calendar/outlook/start/route.ts b/src/app/api/calendar/outlook/start/route.ts
index 1a7257d..b90e54c 100644
--- a/src/app/api/calendar/outlook/start/route.ts
+++ b/src/app/api/calendar/outlook/start/route.ts
@@ -8,7 +8,8 @@ export async function GET(request: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
- return NextResponse.redirect(new URL('/auth/login', request.url));
+ const baseUrl = process.env.NEXTAUTH_URL || request.url;
+ return NextResponse.redirect(new URL('/auth/login', baseUrl));
}
const authUrl = getAuthUrl();
diff --git a/src/app/auth/oauth-complete/page.tsx b/src/app/auth/oauth-complete/page.tsx
index 2b11c29..02df654 100644
--- a/src/app/auth/oauth-complete/page.tsx
+++ b/src/app/auth/oauth-complete/page.tsx
@@ -1,10 +1,10 @@
"use client";
-import { useEffect } from "react";
+import { Suspense, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useSession } from "next-auth/react";
-export default function OAuthCompletePage() {
+function OAuthCompleteContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { update } = useSession();
@@ -52,3 +52,21 @@ export default function OAuthCompletePage() {
);
}
+
+export default function OAuthCompletePage() {
+ return (
+ Loading...