Skip to content

Commit 8b9ba65

Browse files
committed
feat(onboarding): add onboarding module and integrate with dashboard; add Prisma migration
- backend: add onboarding module (`onboardingController`, `onboardingRoutes`, `composition`) - backend: register onboarding routes in `app.ts` and DI in `container.ts` - prisma: update `schema.prisma` and add `20250820054225_onboarding` migration - frontend: add `features/dashboard/onboarding/api.ts` and update `OnboardingProvider` - frontend: wire onboarding into dashboard page and `Dashboard` component DB: run Prisma migrations after pulling these changes
1 parent 86a63af commit 8b9ba65

File tree

13 files changed

+216
-20
lines changed

13 files changed

+216
-20
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
Warnings:
3+
4+
- You are about to drop the `Session` table. If the table is not empty, all the data it contains will be lost.
5+
6+
*/
7+
-- CreateEnum
8+
CREATE TYPE "OnbStatus" AS ENUM ('completed');
9+
10+
-- DropForeignKey
11+
ALTER TABLE "Session" DROP CONSTRAINT "Session_userId_fkey";
12+
13+
-- DropTable
14+
DROP TABLE "Session";
15+
16+
-- CreateTable
17+
CREATE TABLE "Onboarding" (
18+
"id" SERIAL NOT NULL,
19+
"status" "OnbStatus" NOT NULL,
20+
"completed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
21+
"userId" INTEGER NOT NULL,
22+
23+
CONSTRAINT "Onboarding_pkey" PRIMARY KEY ("id")
24+
);
25+
26+
-- CreateIndex
27+
CREATE UNIQUE INDEX "Onboarding_userId_key" ON "Onboarding"("userId");
28+
29+
-- AddForeignKey
30+
ALTER TABLE "Onboarding" ADD CONSTRAINT "Onboarding_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

apps/backend/prisma/schema.prisma

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ model User {
4848
userVocabulary UserVocabulary[]
4949
UnknownWord UnknownWord[]
5050
RefreshToken RefreshToken[]
51+
onboarding Onboarding?
52+
}
53+
54+
model Onboarding {
55+
id Int @id @default(autoincrement())
56+
status OnbStatus
57+
completed_at DateTime @default(now())
58+
user User @relation(fields: [userId], references: [id])
59+
userId Int @unique
5160
}
5261

5362
model RefreshToken {
@@ -80,3 +89,7 @@ enum SessionStatus {
8089
completed
8190
expired
8291
}
92+
93+
enum OnbStatus {
94+
completed
95+
}

apps/backend/src/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { closeRedisConnection, connectRedis } from "./services/redis/redisClient
1313
import {
1414
authRouter,
1515
jobsModule,
16+
onboardingModule,
1617
storyModule,
1718
unknownWordModule,
1819
vocabAssessmentModule,
@@ -49,6 +50,7 @@ app.use("/api/unknown-words", unknownWordModule.router);
4950
app.use("/api/auth", authRouter);
5051
app.use("/api/vocab-assessment", vocabAssessmentModule.router);
5152
app.use("/api/jobs", jobsModule.router);
53+
app.use("/api/onboarding", onboardingModule.router);
5254

5355
app.get("/healthz", (req, res) => {
5456
res.status(200).send("OK");

apps/backend/src/cache/redisStoryCache.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export class RedisStoryCache extends BaseRedisCache {
2424
unknownWords: storyJson.unknownWords || [],
2525
};
2626
} catch (error) {
27-
logger.error("Failed to parse cached story", { error, storyString });
27+
logger.error("Failed to parse cached story", error, storyString);
2828
throw new RedisError("Invalid cached story format", error, { storyString });
2929
}
3030
}

apps/backend/src/container.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createAuthMiddleware } from "./middlewares/authMiddleware";
22
import { buildAuthRouter } from "./modules/auth/authRoutes";
33
import { createAuthModule } from "./modules/auth/composition";
44
import { createJobsModule } from "./modules/jobs/composition";
5+
import { createOnboardingModule } from "./modules/onboarding/composition";
56
import { createSessionModule } from "./modules/session/composition";
67
import { createStoryModule } from "./modules/story/composition";
78
import { TextToSpeechService } from "./modules/story/services/audioAssembler/textToSpeechService";
@@ -66,3 +67,8 @@ export const vocabAssessmentModule = createVocabAssessmentModule({
6667
sessionService: sessionModule.service,
6768
vocabularyService: vocabularyModule.service,
6869
});
70+
71+
export const onboardingModule = createOnboardingModule({
72+
prisma,
73+
authMiddleware,
74+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { PrismaClient } from "@prisma/client";
2+
import { OnboardingController } from "./onboardingController";
3+
import { buildOnboardingRouter } from "./onboardingRoutes";
4+
import { NextFunction, Request, Response } from "express";
5+
6+
export function createOnboardingModule(deps: {
7+
prisma: PrismaClient;
8+
authMiddleware: (req: Request, res: Response, next: NextFunction) => void;
9+
}) {
10+
const controller = new OnboardingController(deps.prisma);
11+
return { router: buildOnboardingRouter(controller, deps.authMiddleware) };
12+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { PrismaError } from "@/errors/PrismaError";
2+
import { formatResponse } from "@/middlewares/responseFormatter";
3+
import { AuthedRequest } from "@/types/types";
4+
import { PrismaClient } from "@prisma/client";
5+
import { Response } from "express";
6+
7+
export class OnboardingController {
8+
constructor(private prisma: PrismaClient) {}
9+
10+
completeOnboarding = async (req: AuthedRequest, res: Response) => {
11+
const { userId } = req.user;
12+
try {
13+
await this.prisma.onboarding.upsert({
14+
where: {
15+
userId,
16+
},
17+
update: {
18+
status: "completed",
19+
},
20+
create: {
21+
user: {
22+
connect: { id: userId },
23+
},
24+
status: "completed",
25+
},
26+
});
27+
} catch (error) {
28+
throw new PrismaError("Failed on complete onboarding", error, { userId });
29+
}
30+
res.status(200).json(formatResponse({ success: true }));
31+
};
32+
33+
checkOnboarding = async (req: AuthedRequest, res: Response) => {
34+
const { userId } = req.user;
35+
try {
36+
const result = await this.prisma.onboarding.findFirst({ where: { userId } });
37+
if (result) {
38+
res.status(200).json(formatResponse({ status: "completed" }));
39+
} else {
40+
res.status(200).json(formatResponse({ status: "not_started" }));
41+
}
42+
} catch (error) {
43+
throw new PrismaError("Failed on check onboarding status", error, { userId });
44+
}
45+
};
46+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { NextFunction, Request, Response, Router } from "express";
2+
import { OnboardingController } from "./onboardingController";
3+
import { asyncHandler } from "@/middlewares/asyncHandler";
4+
5+
export function buildOnboardingRouter(
6+
controller: OnboardingController,
7+
authMiddleware: (req: Request, res: Response, next: NextFunction) => void
8+
) {
9+
const router = Router();
10+
11+
router.post("/complete", authMiddleware, asyncHandler(controller.completeOnboarding));
12+
router.get("/check", authMiddleware, asyncHandler(controller.checkOnboarding));
13+
14+
return router;
15+
}

apps/backend/src/services/redis/baseRedisCache.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export abstract class BaseRedisCache {
2626
if (!value) return null;
2727
return JSON.parse(value) as T;
2828
} catch (error) {
29-
logger.error("[cache] getKey error", { error, key });
29+
logger.error("[cache] getKey error", error, key);
3030
return null;
3131
}
3232
}
@@ -46,7 +46,7 @@ export abstract class BaseRedisCache {
4646
const value = await this.redis.lRange(key, 0, -1);
4747
return value;
4848
} catch (error) {
49-
logger.error("[cache] lRange error", { error, key });
49+
logger.error("[cache] lRange error", error, key);
5050
return [];
5151
}
5252
}

apps/frontend/src/app/(protected)/dashboard/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { Story } from "@/features/story/types";
44
import { ApiError } from "@/types/ApiError";
5-
import { useCallback, useEffect } from "react";
5+
import { useCallback } from "react";
66
import Dashboard from "@/components/Dashboard";
77
import { useWordStatus } from "@/features/story/hooks/useWordStatus";
88
import { useStories } from "@/features/story/hooks/useStories";

0 commit comments

Comments
 (0)