Skip to content

Commit c7ee56a

Browse files
committed
feat: complete new user form workflow
1 parent e8c9e28 commit c7ee56a

14 files changed

Lines changed: 2528 additions & 225 deletions

File tree

acceptance-evidence.json

Lines changed: 1206 additions & 70 deletions
Large diffs are not rendered by default.

acceptance-report.md

Lines changed: 73 additions & 65 deletions
Large diffs are not rendered by default.

apps/web/app/api/achievements/calculate/route.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,44 @@
11
import { NextRequest, NextResponse } from "next/server";
22
import { demoGitHubDataset } from "@gps/core";
33
import { achievementCategories, buildAchievementEmbeds, calculateAchievements, defaultAchievementRules } from "@gps/achievements";
4+
import { GitHubClient } from "@gps/github";
45

56
export async function GET(request: NextRequest) {
67
const username = request.nextUrl.searchParams.get("username") ?? "octocat";
78
const localeParam = request.nextUrl.searchParams.get("locale");
89
const locale = localeParam === "zh-CN" || localeParam === "bilingual" ? localeParam : "en-US";
9-
return achievementResponse(username, locale, demoGitHubDataset(username));
10+
const result = await loadAchievementDataset(username);
11+
return achievementResponse(username, locale, result.dataset, result.source, result.warnings);
1012
}
1113

1214
export async function POST(request: NextRequest) {
1315
const body = await request.json().catch(() => ({}));
1416
const username = typeof body.username === "string" ? body.username : "octocat";
1517
const locale = body.locale === "zh-CN" || body.locale === "bilingual" ? body.locale : "en-US";
16-
const dataset = body.dataset ?? demoGitHubDataset(username);
17-
return achievementResponse(username, locale, dataset);
18+
const result = body.dataset ? { dataset: body.dataset, source: "request-body", warnings: [] } : await loadAchievementDataset(username);
19+
return achievementResponse(username, locale, result.dataset, result.source, result.warnings);
1820
}
1921

20-
function achievementResponse(username: string, locale: "en-US" | "zh-CN" | "bilingual", dataset: ReturnType<typeof demoGitHubDataset>) {
22+
async function loadAchievementDataset(username: string) {
23+
try {
24+
const result = await new GitHubClient(process.env.GITHUB_TOKEN).getDataset(username);
25+
return { dataset: result.dataset, source: result.cache.source, warnings: result.warnings };
26+
} catch (error) {
27+
return {
28+
dataset: demoGitHubDataset(username),
29+
source: "fallback-demo",
30+
warnings: [error instanceof Error ? error.message : "ACHIEVEMENT_DATASET_FALLBACK"]
31+
};
32+
}
33+
}
34+
35+
function achievementResponse(username: string, locale: "en-US" | "zh-CN" | "bilingual", dataset: ReturnType<typeof demoGitHubDataset>, source: string, warnings: string[]) {
2136
const achievements = calculateAchievements(dataset, defaultAchievementRules, locale);
2237

2338
return NextResponse.json({
39+
username,
40+
source,
41+
warnings,
2442
categories: achievementCategories(defaultAchievementRules),
2543
totalScore: achievements.filter((item) => item.unlocked).reduce((total, item) => total + item.score, 0),
2644
achievements,
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import type { NewUserFormDraft } from "@gps/core";
3+
import { defaultNewUserFormDraft, newUserFormDraftSchema, summarizeNewUserFormDraft } from "@gps/core";
4+
5+
type StoredDraft = {
6+
draft: NewUserFormDraft;
7+
savedAt: string;
8+
persistence: "memory" | "postgresql";
9+
};
10+
11+
const globalStore = globalThis as unknown as { gpsNewUserDrafts?: Map<string, StoredDraft> };
12+
const draftStore = globalStore.gpsNewUserDrafts ?? new Map<string, StoredDraft>();
13+
globalStore.gpsNewUserDrafts = draftStore;
14+
15+
export async function GET(request: NextRequest) {
16+
const username = request.nextUrl.searchParams.get("username") ?? "new-developer";
17+
const localeParam = request.nextUrl.searchParams.get("locale");
18+
const locale = localeParam === "zh-CN" || localeParam === "bilingual" ? localeParam : "en-US";
19+
const authenticated = Boolean(request.cookies.get("gps_github_token")?.value);
20+
const stored = draftStore.get(storageKey(request, username));
21+
22+
return NextResponse.json({
23+
authenticated,
24+
persistence: stored?.persistence ?? "memory",
25+
savedAt: stored?.savedAt,
26+
draft: stored?.draft ?? defaultNewUserFormDraft(username, locale),
27+
summary: summarizeNewUserFormDraft(stored?.draft ?? defaultNewUserFormDraft(username, locale)),
28+
acceptanceIds: ["N-FORM-001", "N-FORM-002", "N-FORM-003", "N-FORM-004", "N-FORM-005", "N-FORM-006", "N-FORM-007", "N-FORM-008", "N-FORM-009", "N-FORM-010", "N-FORM-011", "N-FORM-012", "N-FORM-013", "N-FORM-014", "N-FORM-015"]
29+
});
30+
}
31+
32+
export async function POST(request: NextRequest) {
33+
const body = await request.json().catch(() => undefined);
34+
const parsed = newUserFormDraftSchema.safeParse(body);
35+
36+
if (!parsed.success) {
37+
return NextResponse.json(
38+
{
39+
error: "NEW_USER_FORM_INVALID",
40+
message: "The new-user profile form contains invalid fields.",
41+
details: parsed.error.flatten()
42+
},
43+
{ status: 400 }
44+
);
45+
}
46+
47+
const authenticated = Boolean(request.cookies.get("gps_github_token")?.value);
48+
const savedAt = new Date().toISOString();
49+
const dbResult = process.env.DATABASE_URL ? await persistToDatabase(parsed.data).catch((error) => ({ error: error instanceof Error ? error.message : "DATABASE_SAVE_FAILED" })) : undefined;
50+
const persistence: StoredDraft["persistence"] = dbResult && !("error" in dbResult) ? "postgresql" : "memory";
51+
draftStore.set(storageKey(request, parsed.data.username), { draft: parsed.data, savedAt, persistence });
52+
53+
return NextResponse.json({
54+
saved: true,
55+
authenticated,
56+
savedAt,
57+
persistence,
58+
database: dbResult,
59+
summary: summarizeNewUserFormDraft(parsed.data),
60+
nextActions: authenticated
61+
? ["Continue editing the saved profile form.", "Generate README and Pages from the saved draft.", "Deploy when the OAuth permissions are ready."]
62+
: ["Draft saved for this local session.", "Connect GitHub OAuth to persist the draft across sessions.", "Generate README and Pages locally while unauthenticated."],
63+
acceptanceIds: ["N-FORM-015"]
64+
});
65+
}
66+
67+
function storageKey(request: NextRequest, username: string): string {
68+
const cookie = request.cookies.get("gps_github_token")?.value;
69+
return `${cookie ? `oauth:${cookie.slice(0, 16)}` : "anonymous"}:${username.toLowerCase()}`;
70+
}
71+
72+
async function persistToDatabase(draft: NewUserFormDraft) {
73+
const { prisma } = await import("@gps/db");
74+
const profile = await prisma.userProfile.upsert({
75+
where: { githubUsername: draft.username },
76+
create: {
77+
githubUsername: draft.username,
78+
displayName: draft.basics.displayName || draft.basics.nickname || draft.username,
79+
avatarUrl: draft.basics.avatarUrl,
80+
bio: draft.basics.oneLineIntro,
81+
location: draft.privacy.hideLocation ? undefined : draft.basics.location,
82+
blog: draft.basics.blog || draft.basics.website,
83+
email: draft.contact.showEmail ? draft.basics.email : undefined
84+
},
85+
update: {
86+
displayName: draft.basics.displayName || draft.basics.nickname || draft.username,
87+
avatarUrl: draft.basics.avatarUrl,
88+
bio: draft.basics.oneLineIntro,
89+
location: draft.privacy.hideLocation ? undefined : draft.basics.location,
90+
blog: draft.basics.blog || draft.basics.website,
91+
email: draft.contact.showEmail ? draft.basics.email : undefined
92+
}
93+
});
94+
95+
const form = await prisma.newUserProfileForm.upsert({
96+
where: { profileId: profile.id },
97+
create: {
98+
profileId: profile.id,
99+
locale: mapLocale(draft.locale),
100+
currentRole: draft.basics.currentRole,
101+
status: draft.basics.status,
102+
introductionTone: "formal",
103+
lockedCopyBlocks: {},
104+
learningDirections: draft.learning.directions,
105+
highlights: draft.highlights,
106+
contactSettings: draft.contact
107+
},
108+
update: {
109+
locale: mapLocale(draft.locale),
110+
currentRole: draft.basics.currentRole,
111+
status: draft.basics.status,
112+
learningDirections: draft.learning.directions,
113+
highlights: draft.highlights,
114+
contactSettings: draft.contact
115+
}
116+
});
117+
118+
await prisma.$transaction([
119+
prisma.education.deleteMany({ where: { formId: form.id } }),
120+
prisma.skill.deleteMany({ where: { formId: form.id } }),
121+
prisma.programmingLanguage.deleteMany({ where: { formId: form.id } }),
122+
prisma.learningPlan.deleteMany({ where: { formId: form.id } }),
123+
prisma.manualProject.deleteMany({ where: { formId: form.id } })
124+
]);
125+
126+
await Promise.all([
127+
draft.education.length
128+
? prisma.education.createMany({
129+
data: draft.education.map((item) => ({
130+
formId: form.id,
131+
school: item.school,
132+
department: item.department,
133+
major: item.major,
134+
degree: item.degree,
135+
startYear: item.startYear,
136+
graduationYear: item.graduationYear,
137+
grade: item.grade,
138+
gpa: draft.privacy.hideGpa ? undefined : item.gpa,
139+
honors: item.honors,
140+
courses: item.courses,
141+
showInReadme: item.visibility.readme,
142+
showInPages: item.visibility.pages
143+
}))
144+
})
145+
: undefined,
146+
draft.skills.length
147+
? prisma.skill.createMany({
148+
data: draft.skills.map((skill, index) => ({
149+
formId: form.id,
150+
name: skill.name,
151+
category: skill.category,
152+
proficiency: skill.proficiency,
153+
status: skill.status,
154+
showIcon: skill.showIcon,
155+
showBadge: skill.showBadge,
156+
sortOrder: index,
157+
showInReadme: skill.visibility.readme,
158+
showInPages: skill.visibility.pages
159+
}))
160+
})
161+
: undefined,
162+
draft.languages.length
163+
? prisma.programmingLanguage.createMany({
164+
data: draft.languages.map((language) => ({
165+
formId: form.id,
166+
name: language.name,
167+
proficiency: language.proficiency,
168+
isLearning: language.isLearning,
169+
isDailyUse: language.isDailyUse,
170+
isPrimary: language.isPrimary,
171+
showIcon: language.showIcon,
172+
showProgress: language.showProgress,
173+
showBadge: language.showBadge,
174+
showSkillCloud: language.showSkillCloud,
175+
showInReadme: language.visibility.readme,
176+
showInPages: language.visibility.pages
177+
}))
178+
})
179+
: undefined,
180+
prisma.learningPlan.create({
181+
data: {
182+
formId: form.id,
183+
currentFocus: draft.learning.currentFocus,
184+
books: draft.learning.books,
185+
courses: draft.learning.courses,
186+
currentProjects: draft.learning.currentProjects,
187+
shortTermGoals: draft.learning.shortTermGoals,
188+
longTermGoals: draft.learning.longTermGoals,
189+
weeklyPlan: draft.learning.weeklyPlan,
190+
openSourcePlan: draft.learning.openSourcePlan,
191+
jobPlan: draft.learning.jobPlan,
192+
blogPlan: draft.learning.blogPlan,
193+
algorithmPlan: draft.learning.algorithmPlan,
194+
showInReadme: draft.learning.visibility.readme,
195+
showInPages: draft.learning.visibility.pages
196+
}
197+
}),
198+
draft.manualProjects.length
199+
? prisma.manualProject.createMany({
200+
data: draft.manualProjects.map((project) => ({
201+
formId: form.id,
202+
name: project.name,
203+
summary: project.summary,
204+
type: project.type,
205+
status: project.status,
206+
techStack: project.techStack,
207+
highlights: project.highlights,
208+
role: project.role,
209+
repoUrl: project.repoUrl,
210+
demoUrl: project.demoUrl,
211+
imageUrl: project.imageUrl,
212+
videoUrl: project.videoUrl,
213+
featured: project.featured,
214+
showInReadme: project.visibility.readme,
215+
showInPages: project.visibility.pages
216+
}))
217+
})
218+
: undefined
219+
]);
220+
221+
return { profileId: profile.id, formId: form.id };
222+
}
223+
224+
function mapLocale(locale: NewUserFormDraft["locale"]): "EN_US" | "ZH_CN" | "BILINGUAL" {
225+
if (locale === "zh-CN") return "ZH_CN";
226+
if (locale === "bilingual") return "BILINGUAL";
227+
return "EN_US";
228+
}

0 commit comments

Comments
 (0)