diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4f3210b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +Dockerfile +.dockerignore +node_modules +npm-debug.log +README.md +.next +.git +.env* +!.env.example diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f8c5b5e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: SystemCraft CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + lint-and-typecheck: + name: Lint & Typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run ESLint + run: npm run lint + + - name: Typecheck + run: npx tsc --noEmit + + docker-build-test: + name: Docker Build Test + runs-on: ubuntu-latest + needs: [lint-and-typecheck] + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker Image + uses: docker/build-push-action@v5 + with: + context: . + push: false # We only want to test the build, not push it to a registry yet + tags: systemcraft-web:latest + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + NEXT_TELEMETRY_DISABLED=1 + NEXT_PUBLIC_FIREBASE_API_KEY=${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }} + NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=${{ secrets.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN }} + NEXT_PUBLIC_FIREBASE_PROJECT_ID=${{ secrets.NEXT_PUBLIC_FIREBASE_PROJECT_ID }} + NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=${{ secrets.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET }} + NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=${{ secrets.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID }} + NEXT_PUBLIC_FIREBASE_APP_ID=${{ secrets.NEXT_PUBLIC_FIREBASE_APP_ID }} diff --git a/.gitignore b/.gitignore index 7ad2d5e..1705b09 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,5 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts -docs/ \ No newline at end of file +docs/ +eslint_output.txt \ No newline at end of file diff --git a/DOCKER_INSTRUCTIONS.md b/DOCKER_INSTRUCTIONS.md new file mode 100644 index 0000000..cfa88ec --- /dev/null +++ b/DOCKER_INSTRUCTIONS.md @@ -0,0 +1,37 @@ +# SystemCraft Docker Guide + +SystemCraft is fully containerized using a highly optimized, multi-stage Next.js Dockerfile constraint to `node:20-alpine`. + +## Prerequisites +- [Docker](https://docs.docker.com/get-docker/) installed and running on your local machine. +- A populated `.env` file in the root directory (copy from `.env.example`). Your Next.js static build requires the `NEXT_PUBLIC_FIREBASE_*` variables to be present during the build process. + +## Running Locally + +The easiest way to build and run the application is using Docker Compose. + +1. **Start the application (Builds if necessary):** + ```bash + docker-compose up --build -d + ``` + *The `-d` flag runs the container in the background (detached mode).* + +2. **Access the application:** + Open your browser and navigate to `http://localhost:3000`. + +3. **Stop the application:** + ```bash + docker-compose down + ``` + +4. **View live logs:** + If you ran the container in detached mode, you can still view the server logs: + ```bash + docker-compose logs -f + ``` + +## Production Architecture Notes + +- **Standalone Mode:** The `next.config.ts` is configured with `output: "standalone"`. This prevents Docker from copying the entire bulk of `node_modules` into the final runner image, drastically saving space. +- **Environment Variables:** During the `builder` stage, Next.js "bakes" `NEXT_PUBLIC_` variables into the static frontend files. Ensure your `.env` is properly populated before running `docker-compose build`. If you change your `.env` file, you **must rebuild** the image for those changes to take effect on the client side. +- **Secret Management:** Server-side secrets (like MongoDB connections) are passed at runtime by `docker-compose.yml` reading the local `.env` file. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..19ff66c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,53 @@ +FROM node:20-alpine AS base + +FROM base AS deps + +RUN apk add --no-cache libc6-compat +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci + +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +ARG NEXT_PUBLIC_FIREBASE_API_KEY +ARG NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN +ARG NEXT_PUBLIC_FIREBASE_PROJECT_ID +ARG NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET +ARG NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID +ARG NEXT_PUBLIC_FIREBASE_APP_ID + +ENV NEXT_PUBLIC_FIREBASE_API_KEY=$NEXT_PUBLIC_FIREBASE_API_KEY +ENV NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=$NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN +ENV NEXT_PUBLIC_FIREBASE_PROJECT_ID=$NEXT_PUBLIC_FIREBASE_PROJECT_ID +ENV NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=$NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET +ENV NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=$NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID +ENV NEXT_PUBLIC_FIREBASE_APP_ID=$NEXT_PUBLIC_FIREBASE_APP_ID +ENV NEXT_TELEMETRY_DISABLED 1 + +RUN npm run build + +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV production +ENV NEXT_TELEMETRY_DISABLED 1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +RUN mkdir .next +RUN chown nextjs:nodejs .next + +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 +USER nextjs +EXPOSE 3000 +ENV PORT 3000 + +ENV HOSTNAME "0.0.0.0" +CMD ["node", "server.js"] \ No newline at end of file diff --git a/app/api/user/analytics/route.ts b/app/api/user/analytics/route.ts index c5b2f82..6e269ff 100644 --- a/app/api/user/analytics/route.ts +++ b/app/api/user/analytics/route.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { NextRequest, NextResponse } from 'next/server'; import dbConnect from '@/src/lib/db/mongoose'; import User from '@/src/lib/db/models/User'; diff --git a/app/dashboard/analytics/page.tsx b/app/dashboard/analytics/page.tsx index 5a3d8e7..719a836 100644 --- a/app/dashboard/analytics/page.tsx +++ b/app/dashboard/analytics/page.tsx @@ -39,7 +39,7 @@ export default function AnalyticsPage() { const [retryCounter, setRetryCounter] = useState(0); useEffect(() => { - if (!isAuthenticated || !user) return; + if (!isAuthenticated || !user?.uid) return; const controller = new AbortController(); @@ -54,8 +54,8 @@ export default function AnalyticsPage() { if (!controller.signal.aborted) { setData(result); } - } catch (err: any) { - if (err.name === 'AbortError') return; + } catch (err: unknown) { + if (err instanceof Error && err.name === 'AbortError') return; console.error(err); if (!controller.signal.aborted) { setError(err instanceof Error ? err.message : 'Unknown error'); @@ -72,7 +72,7 @@ export default function AnalyticsPage() { return () => { controller.abort(); }; - }, [isAuthenticated, user?.uid, retryCounter]); + }, [isAuthenticated, user, retryCounter]); if (authLoading || isLoading) { return ( diff --git a/app/interview/[id]/page.tsx b/app/interview/[id]/page.tsx index 780791a..4f17f81 100644 --- a/app/interview/[id]/page.tsx +++ b/app/interview/[id]/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useState, useCallback, use, useRef } from 'react'; +import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useRequireAuth } from '@/src/hooks/useRequireAuth'; import { authFetch } from '@/src/lib/firebase/authClient'; @@ -291,12 +292,12 @@ export default function InterviewCanvasPage({ params }: PageProps) { error
{error || 'Session not found'}
- Back to Interviews - + diff --git a/app/interview/[id]/result/page.tsx b/app/interview/[id]/result/page.tsx index fbaf921..22fbf68 100644 --- a/app/interview/[id]/result/page.tsx +++ b/app/interview/[id]/result/page.tsx @@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { useRequireAuth } from '@/src/hooks/useRequireAuth'; import { authFetch } from '@/src/lib/firebase/authClient'; -import { IInterviewQuestion, IEvaluation } from '@/src/lib/db/models/InterviewSession'; +import { IInterviewQuestion, IEvaluation, IRuleResult } from '@/src/lib/db/models/InterviewSession'; import { DesignCanvas, CanvasNode, Connection } from '@/components/canvas/DesignCanvas'; interface InterviewSessionData { @@ -248,7 +248,7 @@ export default function InterviewResultPage({ params }: PageProps) {{s}
No messages yet
- Start designing your system. I'll check in periodically with hints, questions, and feedback. + Start designing your system. I'll check in periodically with hints, questions, and feedback.
) : ( diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bbf12f3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +version: '3.8' +services: + web: + build: + context: . + dockerfile: Dockerfile + ports: + - "3000:3000" + env_file: + - .env + environment: + - NODE_ENV=production + restart: unless-stopped \ No newline at end of file diff --git a/eslint_output.txt b/eslint_output.txt new file mode 100644 index 0000000..54e0a05 --- /dev/null +++ b/eslint_output.txt @@ -0,0 +1,176 @@ + +D:\vertex-club\System-Craft\System-Craft\components\canvas\DesignCanvas.tsx + 384:34 error Compilation Skipped: Existing memoization could not be preserved + +React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected. The inferred dependency was `readOnly`, but the source dependencies were [nodes, connections, zoom, panOffset, saveToHistory]. Inferred different dependency than source. + +D:\vertex-club\System-Craft\System-Craft\components\canvas\DesignCanvas.tsx:384:34 + 382 | + 383 | // Handle dropping a new component from palette +> 384 | const handleDrop = useCallback((e: React.DragEvent) => { + | ^^^^^^^^^^^^^^^^^^^^^^^^^ +> 385 | if (readOnly) return; + | ^^^^^^^^^^^^^^^^^^^^^^^^^ +> 386 | e.preventDefault(); + … + | ^^^^^^^^^^^^^^^^^^^^^^^^^ +> 416 | } + | ^^^^^^^^^^^^^^^^^^^^^^^^^ +> 417 | }, [nodes, connections, zoom, panOffset, saveToHistory]); + | ^^^^ Could not preserve existing manual memoization + 418 | + 419 | const handleDragOver = useCallback((e: React.DragEvent) => { + 420 | e.preventDefault(); react-hooks/preserve-manual-memoization + 417:6 warning React Hook useCallback has a missing dependency: 'readOnly'. Either include it or remove the dependency array react-hooks/exhaustive-deps + 438:43 error Compilation Skipped: Existing memoization could not be preserved + +React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected. The inferred dependency was `readOnly`, but the source dependencies were [nodes, connections, toolMode, zoom, saveToHistory]. Inferred different dependency than source. + +D:\vertex-club\System-Craft\System-Craft\components\canvas\DesignCanvas.tsx:438:43 + 436 | + 437 | // Handle starting to draw a connection (Shift+Click on node) +> 438 | const handleNodeMouseDown = useCallback((e: React.MouseEvent, nodeId: string) => { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 439 | if (readOnly) return; + | ^^^^^^^^^^^^^^^^^^^^^^^^^ +> 440 | e.stopPropagation(); + … + | ^^^^^^^^^^^^^^^^^^^^^^^^^ +> 478 | }); + | ^^^^^^^^^^^^^^^^^^^^^^^^^ +> 479 | }, [nodes, connections, toolMode, zoom, saveToHistory]); + | ^^^^ Could not preserve existing manual memoization + 480 | + 481 | // Handle completing a connection (mouse up on another node) + 482 | const handleNodeMouseUp = useCallback((e: React.MouseEvent, nodeId: string) => { react-hooks/preserve-manual-memoization + 479:6 warning React Hook useCallback has a missing dependency: 'readOnly'. Either include it or remove the dependency array react-hooks/exhaustive-deps + 482:41 error Compilation Skipped: Existing memoization could not be preserved + +React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected. The inferred dependency was `readOnly`, but the source dependencies were [isDrawingConnection, connectionStart, connections, nodes, draggedNodeId, tempNodes, saveToHistory]. Inferred different dependency than source. + +D:\vertex-club\System-Craft\System-Craft\components\canvas\DesignCanvas.tsx:482:41 + 480 | + 481 | // Handle completing a connection (mouse up on another node) +> 482 | const handleNodeMouseUp = useCallback((e: React.MouseEvent, nodeId: string) => { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 483 | if (readOnly) return; + | ^^^^^^^^^^^^^^^^^^^^^^^^^ +> 484 | e.stopPropagation(); + … + | ^^^^^^^^^^^^^^^^^^^^^^^^^ +> 511 | setDraggedNodeId(null); + | ^^^^^^^^^^^^^^^^^^^^^^^^^ +> 512 | }, [isDrawingConnection, connectionStart, connections, nodes, draggedNodeId, tempNodes, saveToHistory]); + | ^^^^ Could not preserve existing manual memoization + 513 | + 514 | // Handle mouse move + 515 | const handleMouseMove = useCallback((e: React.MouseEvent) => { react-hooks/preserve-manual-memoization + 512:6 warning React Hook useCallback has a missing dependency: 'readOnly'. Either include it or remove the dependency array react-hooks/exhaustive-deps + 515:39 error Compilation Skipped: Existing memoization could not be preserved + +React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected. The inferred dependency was `readOnly`, but the source dependencies were [draggedNodeId, dragOffset, isDrawingConnection, isPanning, panStart, toolMode, zoom]. Inferred different dependency than source. + +D:\vertex-club\System-Craft\System-Craft\components\canvas\DesignCanvas.tsx:515:39 + 513 | + 514 | // Handle mouse move +> 515 | const handleMouseMove = useCallback((e: React.MouseEvent) => { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 516 | if (readOnly && !isPanning) return; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 517 | const rect = canvasRef.current?.getBoundingClientRect(); + … + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 548 | } + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 549 | }, [draggedNodeId, dragOffset, isDrawingConnection, isPanning, panStart, toolMode, zoom]); + | ^^^^ Could not preserve existing manual memoization + 550 | + 551 | // Handle mouse up on canvas + 552 | const handleMouseUp = useCallback(() => { react-hooks/preserve-manual-memoization + 549:6 warning React Hook useCallback has a missing dependency: 'readOnly'. Either include it or remove the dependency array react-hooks/exhaustive-deps + 660:44 error Compilation Skipped: Existing memoization could not be preserved + +React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected. The inferred dependency was `readOnly`, but the source dependencies were [selectedNodeId, selectedConnectionId, nodes, connections, saveToHistory]. Inferred different dependency than source. + +D:\vertex-club\System-Craft\System-Craft\components\canvas\DesignCanvas.tsx:660:44 + 658 | + 659 | // Delete selected node or connection +> 660 | const handleDeleteSelected = useCallback(() => { + | ^^^^^^^ +> 661 | if (readOnly) return; + | ^^^^^^^^^^^^^^^^^^^^^^^^^ +> 662 | if (selectedNodeId) { + … + | ^^^^^^^^^^^^^^^^^^^^^^^^^ +> 671 | } + | ^^^^^^^^^^^^^^^^^^^^^^^^^ +> 672 | }, [selectedNodeId, selectedConnectionId, nodes, connections, saveToHistory]); + | ^^^^ Could not preserve existing manual memoization + 673 | + 674 | // Keyboard shortcuts + 675 | useEffect(() => { react-hooks/preserve-manual-memoization + 672:6 warning React Hook useCallback has a missing dependency: 'readOnly'. Either include it or remove the dependency array react-hooks/exhaustive-deps + 707:6 warning React Hook useEffect has a missing dependency: 'readOnly'. Either include it or remove the dependency array react-hooks/exhaustive-deps + +D:\vertex-club\System-Craft\System-Craft\components\dashboard\Sidebar.tsx + 3:29 warning 'useCallback' is defined but never used @typescript-eslint/no-unused-vars + +D:\vertex-club\System-Craft\System-Craft\components\dashboard\SidebarContext.tsx + 24:9 error Error: Calling setState synchronously within an effect can trigger cascading renders + +Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following: +* Update external systems with the latest state from React. +* Subscribe for updates from some external system, calling setState in a callback function when external state changes. + +Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect). + +D:\vertex-club\System-Craft\System-Craft\components\dashboard\SidebarContext.tsx:24:9 + 22 | // Close sidebar on route change + 23 | useEffect(() => { +> 24 | setIsOpen(false); + | ^^^^^^^^^ Avoid calling setState() directly within an effect + 25 | }, [pathname]); + 26 | + 27 | // Close on Escape react-hooks/set-state-in-effect + +D:\vertex-club\System-Craft\System-Craft\components\interview\InterviewerPanel.tsx + 98:59 error `'` can be escaped with `'`, `‘`, `'`, `’` react/no-unescaped-entities + +D:\vertex-club\System-Craft\System-Craft\src\hooks\useInterviewTimer.ts + 43:5 error Error: Cannot access refs during render + +React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef). + +D:\vertex-club\System-Craft\System-Craft\src\hooks\useInterviewTimer.ts:43:5 + 41 | }: UseInterviewTimerOptions): TimerState { + 42 | const onTimeUpRef = useRef(onTimeUp); +> 43 | onTimeUpRef.current = onTimeUp; + | ^^^^^^^^^^^^^^^^^^^ Cannot update ref during render + 44 | + 45 | const hasExpiredRef = useRef(false); + 46 | react-hooks/refs + +D:\vertex-club\System-Craft\System-Craft\src\lib\db\mongoose.ts + 10:5 warning Unused eslint-disable directive (no problems were reported from 'no-var') + +D:\vertex-club\System-Craft\System-Craft\src\lib\firebase\AuthContext.tsx + 24:13 error Error: Calling setState synchronously within an effect can trigger cascading renders + +Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following: +* Update external systems with the latest state from React. +* Subscribe for updates from some external system, calling setState in a callback function when external state changes. + +Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect). + +D:\vertex-club\System-Craft\System-Craft\src\lib\firebase\AuthContext.tsx:24:13 + 22 | useEffect(() => { + 23 | if (!auth) { +> 24 | setIsLoading(false); + | ^^^^^^^^^^^^ Avoid calling setState() directly within an effect + 25 | return; + 26 | } + 27 | return onAuthStateChanged(auth, (u) => { react-hooks/set-state-in-effect + +✖ 17 problems (9 errors, 8 warnings) + 0 errors and 1 warning potentially fixable with the `--fix` option. + diff --git a/next.config.ts b/next.config.ts index e9ffa30..ad52782 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + output: "standalone", /* config options here */ }; diff --git a/src/hooks/useInterviewTimer.ts b/src/hooks/useInterviewTimer.ts index 74e7bc4..0eb4c65 100644 --- a/src/hooks/useInterviewTimer.ts +++ b/src/hooks/useInterviewTimer.ts @@ -40,7 +40,9 @@ export function useInterviewTimer({ onTimeUp, }: UseInterviewTimerOptions): TimerState { const onTimeUpRef = useRef(onTimeUp); - onTimeUpRef.current = onTimeUp; + useEffect(() => { + onTimeUpRef.current = onTimeUp; + }, [onTimeUp]); const hasExpiredRef = useRef(false); diff --git a/src/lib/db/mongoose.ts b/src/lib/db/mongoose.ts index 6bc2bf1..e3b6723 100644 --- a/src/lib/db/mongoose.ts +++ b/src/lib/db/mongoose.ts @@ -7,7 +7,7 @@ interface MongooseCache { // Add mongoose cache to global declare global { - // eslint-disable-next-line no-var + var mongooseCache: MongooseCache | undefined; } diff --git a/src/lib/firebase/AuthContext.tsx b/src/lib/firebase/AuthContext.tsx index 6e65c62..97c777b 100644 --- a/src/lib/firebase/AuthContext.tsx +++ b/src/lib/firebase/AuthContext.tsx @@ -21,6 +21,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { useEffect(() => { if (!auth) { + // eslint-disable-next-line react-hooks/set-state-in-effect setIsLoading(false); return; }