Skip to content

Commit 0374483

Browse files
committed
fix(projects): prevent 500 when updating project
1 parent 5c87e43 commit 0374483

3 files changed

Lines changed: 204 additions & 147 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import { updateProjectSchema } from "../projects-schemas";
4+
5+
test("updateProjectSchema does not inject create defaults", () => {
6+
const result = updateProjectSchema.safeParse({ id: "project-1" });
7+
8+
assert.equal(result.success, true);
9+
if (result.success) {
10+
assert.equal(Object.hasOwn(result.data, "tags"), false);
11+
assert.equal(Object.hasOwn(result.data, "screenshots"), false);
12+
assert.equal(Object.hasOwn(result.data, "projectTags"), false);
13+
}
14+
});
15+
16+
test("updateProjectSchema keeps legacy fields only when provided", () => {
17+
const result = updateProjectSchema.safeParse({
18+
id: "project-1",
19+
imageUrl: "https://example.com/cover.png",
20+
tags: ["ai", "community"],
21+
});
22+
23+
assert.equal(result.success, true);
24+
if (result.success) {
25+
assert.deepEqual(result.data.tags, ["ai", "community"]);
26+
assert.equal(result.data.imageUrl, "https://example.com/cover.png");
27+
}
28+
});
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { PricingType, ProjectStage } from "@prisma/client";
2+
import { z } from "zod";
3+
4+
const teamMemberSchema = z.object({
5+
userId: z.string(),
6+
role: z.enum(["LEADER", "MEMBER"]).default("MEMBER"),
7+
});
8+
9+
const optionalUrlSchema = z
10+
.string()
11+
.optional()
12+
.nullable()
13+
.refine((val) => !val || z.string().url().safeParse(val).success, {
14+
message: "Invalid URL",
15+
});
16+
17+
const optionalVideoUrlSchema = z
18+
.string()
19+
.optional()
20+
.nullable()
21+
.refine((val) => !val || z.string().url().safeParse(val).success, {
22+
message: "Invalid video URL",
23+
});
24+
25+
const legacyImageUrlSchema = z
26+
.string()
27+
.url("Invalid image URL")
28+
.optional()
29+
.nullable()
30+
.or(z.literal(""));
31+
32+
export const createProjectSchema = z.object({
33+
// Basic information
34+
title: z
35+
.string()
36+
.min(1, "Project title is required")
37+
.max(100, "Title too long"),
38+
subtitle: z
39+
.string()
40+
.min(1, "一句话介绍是必需的")
41+
.max(200, "一句话介绍过长"),
42+
description: z.string().max(2000, "作品描述过长").optional().nullable(),
43+
detailedDescription: z.string().optional().nullable(),
44+
url: optionalUrlSchema,
45+
demoVideoUrl: optionalVideoUrlSchema,
46+
47+
// Media
48+
screenshots: z.array(z.string()).max(8, "Too many screenshots").default([]),
49+
50+
// Classification
51+
projectTags: z
52+
.array(z.string())
53+
.max(10, "Too many project tags")
54+
.default([]),
55+
stage: z.nativeEnum(ProjectStage),
56+
pricingType: z.nativeEnum(PricingType).optional().nullable(),
57+
58+
// Milestones
59+
milestones: z.array(z.string()).max(20, "Too many milestones").default([]),
60+
currentMilestone: z.string().optional().nullable(),
61+
62+
// Settings
63+
featured: z.boolean().default(false),
64+
65+
// Legacy fields (for backwards compatibility)
66+
imageUrl: legacyImageUrlSchema,
67+
tags: z.array(z.string()).max(10, "Too many tags").default([]),
68+
69+
// Team recruitment fields
70+
isRecruiting: z.boolean().default(false),
71+
recruitmentStatus: z.string().optional().nullable(),
72+
recruitmentTags: z
73+
.array(z.string())
74+
.max(10, "Too many recruitment tags")
75+
.default([]),
76+
teamDescription: z.string().optional().nullable(),
77+
teamSkills: z
78+
.array(z.string())
79+
.max(20, "Too many required skills")
80+
.default([]),
81+
teamSize: z.number().min(1).max(20).optional().nullable(),
82+
contactInfo: z.string().optional().nullable(),
83+
84+
// Creation experience sharing
85+
creationExperience: z.string().optional().nullable(),
86+
87+
// Team members
88+
teamMembers: z.array(teamMemberSchema).default([]),
89+
});
90+
91+
export const updateProjectSchema = z.object({
92+
id: z.string(),
93+
94+
// Basic information
95+
title: z
96+
.string()
97+
.min(1, "Project title is required")
98+
.max(100, "Title too long")
99+
.optional(),
100+
subtitle: z
101+
.string()
102+
.min(1, "Subtitle is required")
103+
.max(200, "Subtitle too long")
104+
.optional(),
105+
description: z.string().max(2000, "作品描述过长").optional().nullable(),
106+
detailedDescription: z.string().optional().nullable(),
107+
url: optionalUrlSchema,
108+
demoVideoUrl: optionalVideoUrlSchema,
109+
110+
// Media
111+
screenshots: z.array(z.string()).max(8, "Too many screenshots").optional(),
112+
113+
// Classification
114+
projectTags: z
115+
.array(z.string())
116+
.max(10, "Too many project tags")
117+
.optional(),
118+
stage: z.nativeEnum(ProjectStage).optional(),
119+
pricingType: z.nativeEnum(PricingType).optional().nullable(),
120+
121+
// Milestones
122+
milestones: z.array(z.string()).max(20, "Too many milestones").optional(),
123+
currentMilestone: z.string().optional().nullable(),
124+
125+
// Settings
126+
featured: z.boolean().optional(),
127+
128+
// Legacy fields (for backwards compatibility)
129+
imageUrl: legacyImageUrlSchema.optional(),
130+
tags: z.array(z.string()).max(10, "Too many tags").optional(),
131+
132+
// Team recruitment fields
133+
isRecruiting: z.boolean().optional(),
134+
recruitmentStatus: z.string().optional().nullable(),
135+
recruitmentTags: z
136+
.array(z.string())
137+
.max(10, "Too many recruitment tags")
138+
.optional(),
139+
teamDescription: z.string().optional().nullable(),
140+
teamSkills: z
141+
.array(z.string())
142+
.max(20, "Too many required skills")
143+
.optional(),
144+
teamSize: z.number().min(1).max(20).optional().nullable(),
145+
contactInfo: z.string().optional().nullable(),
146+
147+
// Creation experience sharing
148+
creationExperience: z.string().optional().nullable(),
149+
150+
// Team members
151+
teamMembers: z.array(teamMemberSchema).optional(),
152+
});

0 commit comments

Comments
 (0)