diff --git a/.github/workflows/deploy-test.yml b/.github/workflows/deploy-test.yml new file mode 100644 index 0000000..b77c27b --- /dev/null +++ b/.github/workflows/deploy-test.yml @@ -0,0 +1,62 @@ +name: Docker Deploy Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: deploy-test-${{ github.ref }} + cancel-in-progress: true + +jobs: + docker-deploy-test: + name: Fresh-Linux Docker Deploy + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Create minimal .env + run: | + cat <<'EOF' > .env + NEXTAUTH_URL=http://localhost:3100 + NEXTAUTH_SECRET=ci-test-secret-minimum-32-chars-xx + PGADMIN_PASSWORD=ci-pgadmin + EOF + + - name: Start infrastructure (postgres + redis) + run: docker compose up -d postgres redis + + - name: Wait for postgres + run: | + for i in $(seq 1 20); do + docker compose exec -T postgres pg_isready -U capakraken -d capakraken && break + sleep 3 + done + + - name: Build and start app (full profile) + run: docker compose --profile full up -d --build app + + - name: Wait for /api/health (up to 3 minutes) + run: | + for i in $(seq 1 36); do + STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3100/api/health || echo "000") + echo "Attempt $i: HTTP $STATUS" + if [ "$STATUS" = "200" ]; then exit 0; fi + sleep 5 + done + echo "Health check timed out" + docker compose logs app --tail=50 + exit 1 + + - name: Verify health response contains status ok + run: | + BODY=$(curl -sf http://localhost:3100/api/health) + echo "$BODY" + echo "$BODY" | grep '"status":"ok"' + + - name: Show logs on failure + if: failure() + run: docker compose logs --tail=100 diff --git a/LEARNINGS.md b/LEARNINGS.md index 8d92145..639ec1a 100644 --- a/LEARNINGS.md +++ b/LEARNINGS.md @@ -7,6 +7,22 @@ ## Learnings +### 2026-04-02 | DevOps | Prisma schema changes require container restart + +**Problem:** After adding a new column to `schema.prisma` and running `prisma generate` on the host, the running Docker app container still used the old Prisma client (the container's `node_modules` is a named Docker volume, isolated from the host filesystem). Queries referencing the new field (`isActive`) failed at runtime, causing tRPC procedures to return errors. + +**Solution:** Always restart the app container after Prisma schema changes: +``` +docker compose --profile full restart app +``` +The startup script `tooling/docker/app-dev-start.sh` already runs `prisma generate` + `db:migrate:deploy` on every container start — so a restart is sufficient. No rebuild needed unless `pnpm-lock.yaml` or `Dockerfile.dev` changed. + +**Rule:** Prisma schema change checklist: +1. Edit `packages/db/prisma/schema.prisma` +2. Write migration SQL in `packages/db/prisma/migrations/_/migration.sql` +3. Apply migration to the running DB directly (for dev speed): `docker exec capakraken-postgres-1 psql -U capakraken -d capakraken < migration.sql` +4. `docker compose --profile full restart app` — regenerates Prisma client + runs migrations inside the container + ### 2026-03-13 | Architecture | Dispo v2 chargeability calculator design - Pure functions in `packages/engine/src/chargeability/calculator.ts` — no DB imports, all data passed as arguments. - `deriveResourceForecast()` takes SAH + assignment slices per month, returns ratio breakdown (Chg/BD/MD&I/M&O/PD&R/Absence/Unassigned). diff --git a/apps/web/package.json b/apps/web/package.json index b848ab2..6ebe919 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,8 @@ "lint": "next lint", "typecheck": "tsc --project tsconfig.typecheck.json --noEmit", "test:unit": "vitest run", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "test:e2e:email": "playwright test --config playwright.dev.config.ts e2e/dev-system/invite-flow.spec.ts e2e/dev-system/password-reset.spec.ts" }, "dependencies": { "@capakraken/api": "workspace:*", diff --git a/apps/web/public/apple-touch-icon.png b/apps/web/public/apple-touch-icon.png new file mode 100644 index 0000000..0eb66bf Binary files /dev/null and b/apps/web/public/apple-touch-icon.png differ diff --git a/apps/web/public/favicon-16x16.png b/apps/web/public/favicon-16x16.png new file mode 100644 index 0000000..57893c9 Binary files /dev/null and b/apps/web/public/favicon-16x16.png differ diff --git a/apps/web/public/favicon-32x32.png b/apps/web/public/favicon-32x32.png new file mode 100644 index 0000000..cb28564 Binary files /dev/null and b/apps/web/public/favicon-32x32.png differ diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico new file mode 100644 index 0000000..ddcf733 Binary files /dev/null and b/apps/web/public/favicon.ico differ diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..cbc6779 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1,4377 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/icon-192.png b/apps/web/public/icon-192.png index a3ae1e1..9e73b18 100644 Binary files a/apps/web/public/icon-192.png and b/apps/web/public/icon-192.png differ diff --git a/apps/web/public/icon-512.png b/apps/web/public/icon-512.png index 4d234ff..12dd347 100644 Binary files a/apps/web/public/icon-512.png and b/apps/web/public/icon-512.png differ diff --git a/apps/web/public/manifest.json b/apps/web/public/manifest.json index 87ff8bf..e72a0f0 100644 --- a/apps/web/public/manifest.json +++ b/apps/web/public/manifest.json @@ -8,6 +8,7 @@ "theme_color": "#0284c7", "orientation": "any", "icons": [ + { "src": "/favicon.svg", "type": "image/svg+xml", "sizes": "any", "purpose": "any" }, { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } ] diff --git a/apps/web/src/app/invite/[token]/page.tsx b/apps/web/src/app/invite/[token]/page.tsx new file mode 100644 index 0000000..cd91e0f --- /dev/null +++ b/apps/web/src/app/invite/[token]/page.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useState, use } from "react"; +import { useRouter } from "next/navigation"; +import { trpc } from "~/lib/trpc/client.js"; + +export default function AcceptInvitePage({ params }: { params: Promise<{ token: string }> }) { + const { token } = use(params); + const router = useRouter(); + + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [formError, setFormError] = useState(null); + const [done, setDone] = useState(false); + + const { data: invite, isLoading, error: inviteError } = trpc.invite.getInvite.useQuery( + { token }, + { retry: false }, + ); + + const acceptMutation = trpc.invite.acceptInvite.useMutation({ + onSuccess: () => setDone(true), + onError: (err) => setFormError(err.message), + }); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setFormError(null); + if (password.length < 8) { setFormError("Password must be at least 8 characters."); return; } + if (password !== confirm) { setFormError("Passwords do not match."); return; } + await acceptMutation.mutateAsync({ token, password }); + } + + if (isLoading) { + return ( +
+

Loading…

+
+ ); + } + + if (inviteError || !invite) { + return ( +
+
+
🔗
+

+ Invite link invalid or expired +

+

+ {inviteError?.message ?? "This invite link is no longer valid. Please request a new invitation from your administrator."} +

+
+
+ ); + } + + if (done) { + return ( +
+
+
+

+ Account created +

+

Your account has been set up successfully.

+ +
+
+ ); + } + + return ( +
+
+
+

Accept invitation

+

+ You have been invited as {invite.role} to CapaKraken. + Set a password to activate your account ({invite.email}). +

+
+ +
+ {formError && ( +
+ {formError} +
+ )} + +
+ + setPassword(e.target.value)} + required + minLength={8} + placeholder="At least 8 characters" + className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" + /> +
+ +
+ + setConfirm(e.target.value)} + required + placeholder="Repeat your password" + className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" + /> +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index d83023f..53f7954 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -23,6 +23,15 @@ export const metadata: Metadata = { title: "CapaKraken — Resource & Capacity Planning", description: "Interactive resource planning and project staffing tool", manifest: "/manifest.json", + icons: { + icon: [ + { url: "/favicon.svg", type: "image/svg+xml" }, + { url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" }, + { url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" }, + { url: "/favicon.ico" }, + ], + apple: [{ url: "/apple-touch-icon.png", sizes: "180x180" }], + }, appleWebApp: { capable: true, statusBarStyle: "default", @@ -51,7 +60,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo return ( -