@topogram/template-todo 0.1.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -0
- package/implementation/README.md +9 -0
- package/implementation/backend/reference.js +206 -0
- package/implementation/backend/repository-reference.js +74 -0
- package/implementation/backend/repository-renderers.js +442 -0
- package/implementation/index.js +53 -0
- package/implementation/runtime/check-renderers.js +215 -0
- package/implementation/runtime/checks.js +120 -0
- package/implementation/runtime/reference.js +92 -0
- package/implementation/web/reference.js +51 -0
- package/implementation/web/renderers.js +1223 -0
- package/implementation/web/screens-reference.js +15 -0
- package/package.json +31 -0
- package/topogram/actors/actor-user.tg +6 -0
- package/topogram/capabilities/cap-complete-task.tg +12 -0
- package/topogram/capabilities/cap-create-project.tg +11 -0
- package/topogram/capabilities/cap-create-task.tg +14 -0
- package/topogram/capabilities/cap-create-user.tg +11 -0
- package/topogram/capabilities/cap-delete-task.tg +12 -0
- package/topogram/capabilities/cap-download-task-export.tg +10 -0
- package/topogram/capabilities/cap-export-tasks.tg +11 -0
- package/topogram/capabilities/cap-get-project.tg +11 -0
- package/topogram/capabilities/cap-get-task-export-job.tg +11 -0
- package/topogram/capabilities/cap-get-task.tg +11 -0
- package/topogram/capabilities/cap-get-user.tg +11 -0
- package/topogram/capabilities/cap-list-projects.tg +11 -0
- package/topogram/capabilities/cap-list-tasks.tg +11 -0
- package/topogram/capabilities/cap-list-users.tg +11 -0
- package/topogram/capabilities/cap-update-project.tg +12 -0
- package/topogram/capabilities/cap-update-task.tg +12 -0
- package/topogram/capabilities/cap-update-user.tg +12 -0
- package/topogram/components/component-ui-task-board.tg +33 -0
- package/topogram/components/component-ui-task-calendar.tg +30 -0
- package/topogram/components/component-ui-task-summary.tg +23 -0
- package/topogram/components/component-ui-task-table.tg +34 -0
- package/topogram/decisions/decision-task-ownership.tg +9 -0
- package/topogram/docs/glossary/user.md +22 -0
- package/topogram/docs/journeys/task-creation-and-ownership.md +57 -0
- package/topogram/entities/entity-project.tg +28 -0
- package/topogram/entities/entity-task.tg +38 -0
- package/topogram/entities/entity-user.tg +24 -0
- package/topogram/enums/enum-export-job-status.tg +3 -0
- package/topogram/enums/enum-project-status.tg +3 -0
- package/topogram/enums/enum-task-priority.tg +3 -0
- package/topogram/enums/enum-task-status.tg +3 -0
- package/topogram/operations/operation-task-creation-monitoring.tg +10 -0
- package/topogram/projections/proj-api.tg +177 -0
- package/topogram/projections/proj-db-postgres.tg +55 -0
- package/topogram/projections/proj-db-sqlite.tg +47 -0
- package/topogram/projections/proj-ui-shared.tg +133 -0
- package/topogram/projections/proj-ui-web-react.tg +92 -0
- package/topogram/projections/proj-ui-web.tg +92 -0
- package/topogram/rules/rule-no-task-creation-in-archived-project.tg +10 -0
- package/topogram/rules/rule-only-active-users-may-own-tasks.tg +10 -0
- package/topogram/shapes/shape-input-complete-task.tg +11 -0
- package/topogram/shapes/shape-input-create-project.tg +6 -0
- package/topogram/shapes/shape-input-create-task.tg +6 -0
- package/topogram/shapes/shape-input-create-user.tg +6 -0
- package/topogram/shapes/shape-input-delete-task.tg +10 -0
- package/topogram/shapes/shape-input-export-tasks.tg +13 -0
- package/topogram/shapes/shape-input-get-project.tg +10 -0
- package/topogram/shapes/shape-input-get-task-export-job.tg +10 -0
- package/topogram/shapes/shape-input-get-task.tg +10 -0
- package/topogram/shapes/shape-input-get-user.tg +10 -0
- package/topogram/shapes/shape-input-list-projects.tg +11 -0
- package/topogram/shapes/shape-input-list-tasks.tg +14 -0
- package/topogram/shapes/shape-input-list-users.tg +11 -0
- package/topogram/shapes/shape-input-update-project.tg +14 -0
- package/topogram/shapes/shape-input-update-task.tg +16 -0
- package/topogram/shapes/shape-input-update-user.tg +13 -0
- package/topogram/shapes/shape-output-project-card.tg +6 -0
- package/topogram/shapes/shape-output-project-detail.tg +6 -0
- package/topogram/shapes/shape-output-task-card.tg +19 -0
- package/topogram/shapes/shape-output-task-detail.tg +6 -0
- package/topogram/shapes/shape-output-task-export-callback.tg +14 -0
- package/topogram/shapes/shape-output-task-export-job.tg +13 -0
- package/topogram/shapes/shape-output-task-export-status.tg +17 -0
- package/topogram/shapes/shape-output-user-card.tg +6 -0
- package/topogram/shapes/shape-output-user-detail.tg +6 -0
- package/topogram/terms/term-user.tg +5 -0
- package/topogram/verifications/verification-create-task-policy.tg +15 -0
- package/topogram/verifications/verification-runtime-smoke.tg +16 -0
- package/topogram/verifications/verification-task-runtime-flow.tg +31 -0
- package/topogram-template.json +11 -0
- package/topogram.project.json +53 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
function buildPrismaRepositoryBody({
|
|
2
|
+
repositoryInterfaceName,
|
|
3
|
+
prismaRepositoryClassName,
|
|
4
|
+
repositoryReference
|
|
5
|
+
}) {
|
|
6
|
+
const exportContentType = repositoryReference.export.contentType;
|
|
7
|
+
const exportFilename = repositoryReference.export.filename;
|
|
8
|
+
const markInputType = "MarkExportJobCompletedInput";
|
|
9
|
+
const markOutputType = "MarkExportJobCompletedResult";
|
|
10
|
+
const lines = [];
|
|
11
|
+
lines.push("");
|
|
12
|
+
lines.push("type StoredExportJob = GetTaskExportJobResult & {");
|
|
13
|
+
lines.push(" archive: Uint8Array;");
|
|
14
|
+
lines.push("};");
|
|
15
|
+
lines.push("");
|
|
16
|
+
lines.push("function iso(value: Date | string | null | undefined): string | undefined {");
|
|
17
|
+
lines.push(" if (!value) return undefined;");
|
|
18
|
+
lines.push(" return value instanceof Date ? value.toISOString() : value;");
|
|
19
|
+
lines.push("}");
|
|
20
|
+
lines.push("");
|
|
21
|
+
lines.push("function nextCursor<T extends { created_at: Date | string }>(items: T[]): string {");
|
|
22
|
+
lines.push(" return items.length > 0 ? iso(items[items.length - 1]!.created_at) || \"\" : \"\";");
|
|
23
|
+
lines.push("}");
|
|
24
|
+
lines.push("");
|
|
25
|
+
lines.push("function isUniqueConstraintError(error: unknown): boolean {");
|
|
26
|
+
lines.push(" return Boolean(error && typeof error === \"object\" && \"code\" in error && (error as { code?: string }).code === \"P2002\");");
|
|
27
|
+
lines.push("}");
|
|
28
|
+
lines.push("");
|
|
29
|
+
lines.push("function mapProjectRecord(project: {");
|
|
30
|
+
lines.push(" id: string;");
|
|
31
|
+
lines.push(" name: string;");
|
|
32
|
+
lines.push(" description: string | null;");
|
|
33
|
+
lines.push(" status: \"active\" | \"archived\";");
|
|
34
|
+
lines.push(" owner_id: string | null;");
|
|
35
|
+
lines.push(" created_at: Date | string;");
|
|
36
|
+
lines.push("}): GetProjectResult {");
|
|
37
|
+
lines.push(" return {");
|
|
38
|
+
lines.push(" id: project.id,");
|
|
39
|
+
lines.push(" name: project.name,");
|
|
40
|
+
lines.push(" description: project.description ?? undefined,");
|
|
41
|
+
lines.push(" status: project.status,");
|
|
42
|
+
lines.push(" owner_id: project.owner_id ?? undefined,");
|
|
43
|
+
lines.push(" created_at: iso(project.created_at)!");
|
|
44
|
+
lines.push(" };");
|
|
45
|
+
lines.push("}");
|
|
46
|
+
lines.push("");
|
|
47
|
+
lines.push("function mapUserRecord(user: {");
|
|
48
|
+
lines.push(" id: string;");
|
|
49
|
+
lines.push(" email: string;");
|
|
50
|
+
lines.push(" display_name: string;");
|
|
51
|
+
lines.push(" is_active: boolean;");
|
|
52
|
+
lines.push(" created_at: Date | string;");
|
|
53
|
+
lines.push("}): GetUserResult {");
|
|
54
|
+
lines.push(" return {");
|
|
55
|
+
lines.push(" id: user.id,");
|
|
56
|
+
lines.push(" email: user.email,");
|
|
57
|
+
lines.push(" display_name: user.display_name,");
|
|
58
|
+
lines.push(" is_active: user.is_active,");
|
|
59
|
+
lines.push(" created_at: iso(user.created_at)!");
|
|
60
|
+
lines.push(" };");
|
|
61
|
+
lines.push("}");
|
|
62
|
+
lines.push("");
|
|
63
|
+
lines.push("function mapTaskRecord(task: {");
|
|
64
|
+
lines.push(" id: string;");
|
|
65
|
+
lines.push(" title: string;");
|
|
66
|
+
lines.push(" description: string | null;");
|
|
67
|
+
lines.push(' status: "draft" | "active" | "completed" | "archived";');
|
|
68
|
+
lines.push(' priority: "low" | "medium" | "high";');
|
|
69
|
+
lines.push(" owner_id: string | null;");
|
|
70
|
+
lines.push(" project_id: string;");
|
|
71
|
+
lines.push(" created_at: Date | string;");
|
|
72
|
+
lines.push(" updated_at: Date | string;");
|
|
73
|
+
lines.push(" completed_at: Date | string | null;");
|
|
74
|
+
lines.push(" due_at: Date | string | null;");
|
|
75
|
+
lines.push("}): GetTaskResult {");
|
|
76
|
+
lines.push(" return {");
|
|
77
|
+
lines.push(" id: task.id,");
|
|
78
|
+
lines.push(" title: task.title,");
|
|
79
|
+
lines.push(" description: task.description ?? undefined,");
|
|
80
|
+
lines.push(" status: task.status,");
|
|
81
|
+
lines.push(" priority: task.priority,");
|
|
82
|
+
lines.push(" owner_id: task.owner_id ?? undefined,");
|
|
83
|
+
lines.push(" project_id: task.project_id,");
|
|
84
|
+
lines.push(" created_at: iso(task.created_at)!,");
|
|
85
|
+
lines.push(" updated_at: iso(task.updated_at)!,");
|
|
86
|
+
lines.push(" completed_at: iso(task.completed_at),");
|
|
87
|
+
lines.push(" due_at: iso(task.due_at)");
|
|
88
|
+
lines.push(" };");
|
|
89
|
+
lines.push("}");
|
|
90
|
+
lines.push("");
|
|
91
|
+
lines.push(`export class ${prismaRepositoryClassName} implements ${repositoryInterfaceName} {`);
|
|
92
|
+
lines.push(" private readonly exportJobs = new Map<string, StoredExportJob>();");
|
|
93
|
+
lines.push("");
|
|
94
|
+
lines.push(" constructor(private readonly prisma: PrismaClient) {}");
|
|
95
|
+
lines.push("");
|
|
96
|
+
lines.push(" async listProjectOptions(): Promise<LookupOption[]> {");
|
|
97
|
+
lines.push(" const projects = await this.prisma.project.findMany({");
|
|
98
|
+
lines.push(' where: { status: { not: "archived" } },');
|
|
99
|
+
lines.push(' orderBy: [{ name: "asc" }]');
|
|
100
|
+
lines.push(" });");
|
|
101
|
+
lines.push(" return projects.map((project) => ({ value: project.id, label: project.name }));");
|
|
102
|
+
lines.push(" }");
|
|
103
|
+
lines.push("");
|
|
104
|
+
lines.push(" async listUserOptions(): Promise<LookupOption[]> {");
|
|
105
|
+
lines.push(" const users = await this.prisma.user.findMany({");
|
|
106
|
+
lines.push(" where: { is_active: true },");
|
|
107
|
+
lines.push(' orderBy: [{ display_name: "asc" }]');
|
|
108
|
+
lines.push(" });");
|
|
109
|
+
lines.push(" return users.map((user) => ({ value: user.id, label: user.display_name }));");
|
|
110
|
+
lines.push(" }");
|
|
111
|
+
lines.push("");
|
|
112
|
+
lines.push(" async getProject(input: GetProjectInput): Promise<GetProjectResult> {");
|
|
113
|
+
lines.push(" const project = await this.prisma.project.findUnique({ where: { id: input.project_id } });");
|
|
114
|
+
lines.push(' if (!project) throw new HttpError(404, "cap_get_project_not_found", "Project not found");');
|
|
115
|
+
lines.push(" return mapProjectRecord(project);");
|
|
116
|
+
lines.push(" }");
|
|
117
|
+
lines.push("");
|
|
118
|
+
lines.push(" async listProjects(input: ListProjectsInput): Promise<ListProjectsResult> {");
|
|
119
|
+
lines.push(" const take = Math.min(input.limit ?? 25, 100);");
|
|
120
|
+
lines.push(" const projects = await this.prisma.project.findMany({");
|
|
121
|
+
lines.push(" where: { ...(input.after ? { created_at: { lt: new Date(input.after) } } : {}) },");
|
|
122
|
+
lines.push(' orderBy: [{ created_at: "desc" }],');
|
|
123
|
+
lines.push(" take: take + 1");
|
|
124
|
+
lines.push(" });");
|
|
125
|
+
lines.push(" const page = projects.slice(0, take).map(mapProjectRecord);");
|
|
126
|
+
lines.push(" return {");
|
|
127
|
+
lines.push(" items: page,");
|
|
128
|
+
lines.push(" next_cursor: nextCursor(projects.slice(0, take))");
|
|
129
|
+
lines.push(" };");
|
|
130
|
+
lines.push(" }");
|
|
131
|
+
lines.push("");
|
|
132
|
+
lines.push(" async createProject(input: CreateProjectInput): Promise<CreateProjectResult> {");
|
|
133
|
+
lines.push(" const now = new Date();");
|
|
134
|
+
lines.push(" if (input.owner_id) {");
|
|
135
|
+
lines.push(" const owner = await this.prisma.user.findUnique({ where: { id: input.owner_id } });");
|
|
136
|
+
lines.push(' if (!owner || !owner.is_active) throw new HttpError(400, "cap_create_project_invalid_request", "Project owner must be active");');
|
|
137
|
+
lines.push(" }");
|
|
138
|
+
lines.push(" const project = await this.prisma.project.create({");
|
|
139
|
+
lines.push(" data: {");
|
|
140
|
+
lines.push(" id: crypto.randomUUID(),");
|
|
141
|
+
lines.push(" name: input.name,");
|
|
142
|
+
lines.push(" description: input.description ?? null,");
|
|
143
|
+
lines.push(' status: input.status ?? "active",');
|
|
144
|
+
lines.push(" owner_id: input.owner_id ?? null,");
|
|
145
|
+
lines.push(" created_at: now");
|
|
146
|
+
lines.push(" }");
|
|
147
|
+
lines.push(" }).catch((error) => {");
|
|
148
|
+
lines.push(" if (isUniqueConstraintError(error)) {");
|
|
149
|
+
lines.push(' throw new HttpError(409, "cap_create_project_conflict", "Project name already exists");');
|
|
150
|
+
lines.push(" }");
|
|
151
|
+
lines.push(" throw error;");
|
|
152
|
+
lines.push(" });");
|
|
153
|
+
lines.push(" return mapProjectRecord(project);");
|
|
154
|
+
lines.push(" }");
|
|
155
|
+
lines.push("");
|
|
156
|
+
lines.push(" async updateProject(input: UpdateProjectInput): Promise<UpdateProjectResult> {");
|
|
157
|
+
lines.push(" if (input.owner_id) {");
|
|
158
|
+
lines.push(" const owner = await this.prisma.user.findUnique({ where: { id: input.owner_id } });");
|
|
159
|
+
lines.push(' if (!owner || !owner.is_active) throw new HttpError(400, "cap_update_project_invalid_request", "Project owner must be active");');
|
|
160
|
+
lines.push(" }");
|
|
161
|
+
lines.push(" const project = await this.prisma.project.update({");
|
|
162
|
+
lines.push(" where: { id: input.project_id },");
|
|
163
|
+
lines.push(" data: {");
|
|
164
|
+
lines.push(" ...(input.name !== undefined ? { name: input.name } : {}),");
|
|
165
|
+
lines.push(" ...(input.description !== undefined ? { description: input.description ?? null } : {}),");
|
|
166
|
+
lines.push(" ...(input.status !== undefined ? { status: input.status } : {}),");
|
|
167
|
+
lines.push(" ...(input.owner_id !== undefined ? { owner_id: input.owner_id ?? null } : {})");
|
|
168
|
+
lines.push(" }");
|
|
169
|
+
lines.push(" }).catch((error) => {");
|
|
170
|
+
lines.push(" if (isUniqueConstraintError(error)) {");
|
|
171
|
+
lines.push(' throw new HttpError(409, "cap_update_project_conflict", "Project name already exists");');
|
|
172
|
+
lines.push(" }");
|
|
173
|
+
lines.push(' throw new HttpError(404, "cap_get_project_not_found", error instanceof Error ? error.message : "Project not found");');
|
|
174
|
+
lines.push(" });");
|
|
175
|
+
lines.push(" return mapProjectRecord(project);");
|
|
176
|
+
lines.push(" }");
|
|
177
|
+
lines.push("");
|
|
178
|
+
lines.push(" async getUser(input: GetUserInput): Promise<GetUserResult> {");
|
|
179
|
+
lines.push(" const user = await this.prisma.user.findUnique({ where: { id: input.user_id } });");
|
|
180
|
+
lines.push(' if (!user) throw new HttpError(404, "cap_get_user_not_found", "User not found");');
|
|
181
|
+
lines.push(" return mapUserRecord(user);");
|
|
182
|
+
lines.push(" }");
|
|
183
|
+
lines.push("");
|
|
184
|
+
lines.push(" async listUsers(input: ListUsersInput): Promise<ListUsersResult> {");
|
|
185
|
+
lines.push(" const take = Math.min(input.limit ?? 25, 100);");
|
|
186
|
+
lines.push(" const users = await this.prisma.user.findMany({");
|
|
187
|
+
lines.push(" where: { ...(input.after ? { created_at: { lt: new Date(input.after) } } : {}) },");
|
|
188
|
+
lines.push(' orderBy: [{ created_at: "desc" }],');
|
|
189
|
+
lines.push(" take: take + 1");
|
|
190
|
+
lines.push(" });");
|
|
191
|
+
lines.push(" const page = users.slice(0, take).map(mapUserRecord);");
|
|
192
|
+
lines.push(" return {");
|
|
193
|
+
lines.push(" items: page,");
|
|
194
|
+
lines.push(" next_cursor: nextCursor(users.slice(0, take))");
|
|
195
|
+
lines.push(" };");
|
|
196
|
+
lines.push(" }");
|
|
197
|
+
lines.push("");
|
|
198
|
+
lines.push(" async createUser(input: CreateUserInput): Promise<CreateUserResult> {");
|
|
199
|
+
lines.push(" const now = new Date();");
|
|
200
|
+
lines.push(" const user = await this.prisma.user.create({");
|
|
201
|
+
lines.push(" data: {");
|
|
202
|
+
lines.push(" id: crypto.randomUUID(),");
|
|
203
|
+
lines.push(" email: input.email,");
|
|
204
|
+
lines.push(" display_name: input.display_name,");
|
|
205
|
+
lines.push(" is_active: input.is_active ?? true,");
|
|
206
|
+
lines.push(" created_at: now");
|
|
207
|
+
lines.push(" }");
|
|
208
|
+
lines.push(" }).catch((error) => {");
|
|
209
|
+
lines.push(" if (isUniqueConstraintError(error)) {");
|
|
210
|
+
lines.push(' throw new HttpError(409, "cap_create_user_conflict", "User email already exists");');
|
|
211
|
+
lines.push(" }");
|
|
212
|
+
lines.push(" throw error;");
|
|
213
|
+
lines.push(" });");
|
|
214
|
+
lines.push(" return mapUserRecord(user);");
|
|
215
|
+
lines.push(" }");
|
|
216
|
+
lines.push("");
|
|
217
|
+
lines.push(" async updateUser(input: UpdateUserInput): Promise<UpdateUserResult> {");
|
|
218
|
+
lines.push(" const user = await this.prisma.user.update({");
|
|
219
|
+
lines.push(" where: { id: input.user_id },");
|
|
220
|
+
lines.push(" data: {");
|
|
221
|
+
lines.push(" ...(input.email !== undefined ? { email: input.email } : {}),");
|
|
222
|
+
lines.push(" ...(input.display_name !== undefined ? { display_name: input.display_name } : {}),");
|
|
223
|
+
lines.push(" ...(input.is_active !== undefined ? { is_active: input.is_active } : {})");
|
|
224
|
+
lines.push(" }");
|
|
225
|
+
lines.push(" }).catch((error) => {");
|
|
226
|
+
lines.push(" if (isUniqueConstraintError(error)) {");
|
|
227
|
+
lines.push(' throw new HttpError(409, "cap_update_user_conflict", "User email already exists");');
|
|
228
|
+
lines.push(" }");
|
|
229
|
+
lines.push(' throw new HttpError(404, "cap_get_user_not_found", error instanceof Error ? error.message : "User not found");');
|
|
230
|
+
lines.push(" });");
|
|
231
|
+
lines.push(" return mapUserRecord(user);");
|
|
232
|
+
lines.push(" }");
|
|
233
|
+
lines.push("");
|
|
234
|
+
lines.push(" async getTask(input: GetTaskInput): Promise<GetTaskResult> {");
|
|
235
|
+
lines.push(" const task = await this.prisma.task.findUnique({ where: { id: input.task_id } });");
|
|
236
|
+
lines.push(' if (!task) throw new HttpError(404, "cap_get_task_not_found", "Task not found");');
|
|
237
|
+
lines.push(" return mapTaskRecord(task);");
|
|
238
|
+
lines.push(" }");
|
|
239
|
+
lines.push("");
|
|
240
|
+
lines.push(" async listTasks(input: ListTasksInput): Promise<ListTasksResult> {");
|
|
241
|
+
lines.push(" const take = Math.min(input.limit ?? 25, 100);");
|
|
242
|
+
lines.push(" const tasks = await this.prisma.task.findMany({");
|
|
243
|
+
lines.push(" where: {");
|
|
244
|
+
lines.push(" project_id: input.project_id ?? undefined,");
|
|
245
|
+
lines.push(" owner_id: input.owner_id ?? undefined,");
|
|
246
|
+
lines.push(" status: input.status ?? undefined,");
|
|
247
|
+
lines.push(" ...(input.after ? { created_at: { lt: new Date(input.after) } } : {})");
|
|
248
|
+
lines.push(" },");
|
|
249
|
+
lines.push(' orderBy: [{ created_at: "desc" }],');
|
|
250
|
+
lines.push(" take: take + 1");
|
|
251
|
+
lines.push(" });");
|
|
252
|
+
lines.push(" const page = tasks.slice(0, take).map(mapTaskRecord);");
|
|
253
|
+
lines.push(" return {");
|
|
254
|
+
lines.push(" items: page,");
|
|
255
|
+
lines.push(" next_cursor: nextCursor(tasks.slice(0, take))");
|
|
256
|
+
lines.push(" };");
|
|
257
|
+
lines.push(" }");
|
|
258
|
+
lines.push("");
|
|
259
|
+
lines.push(" async createTask(input: CreateTaskInput): Promise<CreateTaskResult> {");
|
|
260
|
+
lines.push(" const project = await this.prisma.project.findUnique({ where: { id: input.project_id } });");
|
|
261
|
+
lines.push(' if (!project) throw new HttpError(400, "cap_create_task_invalid_request", "Project does not exist");');
|
|
262
|
+
lines.push(' if (project.status === "archived") throw new HttpError(409, "rule_no_task_creation_in_archived_project", "Cannot create tasks in archived projects");');
|
|
263
|
+
lines.push(" if (input.owner_id) {");
|
|
264
|
+
lines.push(" const owner = await this.prisma.user.findUnique({ where: { id: input.owner_id } });");
|
|
265
|
+
lines.push(' if (!owner || !owner.is_active) throw new HttpError(400, "rule_only_active_users_may_own_tasks", "Task owner must be active");');
|
|
266
|
+
lines.push(" }");
|
|
267
|
+
lines.push(" const now = new Date();");
|
|
268
|
+
lines.push(" const task = await this.prisma.task.create({");
|
|
269
|
+
lines.push(" data: {");
|
|
270
|
+
lines.push(" id: crypto.randomUUID(),");
|
|
271
|
+
lines.push(" title: input.title,");
|
|
272
|
+
lines.push(" description: input.description ?? null,");
|
|
273
|
+
lines.push(' status: input.owner_id ? "active" : "draft",');
|
|
274
|
+
lines.push(' priority: input.priority ?? "medium",');
|
|
275
|
+
lines.push(" owner_id: input.owner_id ?? null,");
|
|
276
|
+
lines.push(" project_id: input.project_id,");
|
|
277
|
+
lines.push(" created_at: now,");
|
|
278
|
+
lines.push(" updated_at: now,");
|
|
279
|
+
lines.push(" completed_at: null,");
|
|
280
|
+
lines.push(" due_at: input.due_at ? new Date(input.due_at) : null");
|
|
281
|
+
lines.push(" }");
|
|
282
|
+
lines.push(" });");
|
|
283
|
+
lines.push(" return mapTaskRecord(task);");
|
|
284
|
+
lines.push(" }");
|
|
285
|
+
lines.push("");
|
|
286
|
+
lines.push(" async updateTask(input: UpdateTaskInput): Promise<UpdateTaskResult> {");
|
|
287
|
+
lines.push(" if (input.owner_id) {");
|
|
288
|
+
lines.push(" const owner = await this.prisma.user.findUnique({ where: { id: input.owner_id } });");
|
|
289
|
+
lines.push(' if (!owner || !owner.is_active) throw new HttpError(400, "rule_only_active_users_may_own_tasks", "Task owner must be active");');
|
|
290
|
+
lines.push(" }");
|
|
291
|
+
lines.push(" const task = await this.prisma.task.update({");
|
|
292
|
+
lines.push(" where: { id: input.task_id },");
|
|
293
|
+
lines.push(" data: {");
|
|
294
|
+
lines.push(" ...(input.title !== undefined ? { title: input.title } : {}),");
|
|
295
|
+
lines.push(" ...(input.description !== undefined ? { description: input.description ?? null } : {}),");
|
|
296
|
+
lines.push(" ...(input.priority !== undefined ? { priority: input.priority } : {}),");
|
|
297
|
+
lines.push(" ...(input.owner_id !== undefined ? { owner_id: input.owner_id ?? null } : {}),");
|
|
298
|
+
lines.push(" ...(input.due_at !== undefined ? { due_at: input.due_at ? new Date(input.due_at) : null } : {}),");
|
|
299
|
+
lines.push(" ...(input.status !== undefined ? { status: input.status } : {}),");
|
|
300
|
+
lines.push(" updated_at: new Date()");
|
|
301
|
+
lines.push(" }");
|
|
302
|
+
lines.push(" }).catch((error) => {");
|
|
303
|
+
lines.push(' throw new HttpError(404, "task_not_found", error instanceof Error ? error.message : "Task not found");');
|
|
304
|
+
lines.push(" });");
|
|
305
|
+
lines.push(" return mapTaskRecord(task);");
|
|
306
|
+
lines.push(" }");
|
|
307
|
+
lines.push("");
|
|
308
|
+
lines.push(" async completeTask(input: CompleteTaskInput): Promise<CompleteTaskResult> {");
|
|
309
|
+
lines.push(" const completedAt = input.completed_at ? new Date(input.completed_at) : new Date();");
|
|
310
|
+
lines.push(" const task = await this.prisma.task.update({");
|
|
311
|
+
lines.push(" where: { id: input.task_id },");
|
|
312
|
+
lines.push(' data: { status: "completed", completed_at: completedAt, updated_at: new Date() }');
|
|
313
|
+
lines.push(" }).catch((error) => {");
|
|
314
|
+
lines.push(' throw new HttpError(404, "task_not_found", error instanceof Error ? error.message : "Task not found");');
|
|
315
|
+
lines.push(" });");
|
|
316
|
+
lines.push(" return mapTaskRecord(task);");
|
|
317
|
+
lines.push(" }");
|
|
318
|
+
lines.push("");
|
|
319
|
+
lines.push(" async deleteTask(input: DeleteTaskInput): Promise<DeleteTaskResult> {");
|
|
320
|
+
lines.push(" const task = await this.prisma.task.update({");
|
|
321
|
+
lines.push(" where: { id: input.task_id },");
|
|
322
|
+
lines.push(' data: { status: "archived", updated_at: new Date() }');
|
|
323
|
+
lines.push(" }).catch((error) => {");
|
|
324
|
+
lines.push(' throw new HttpError(404, "cap_delete_task_not_found", error instanceof Error ? error.message : "Task not found");');
|
|
325
|
+
lines.push(" });");
|
|
326
|
+
lines.push(" return mapTaskRecord(task);");
|
|
327
|
+
lines.push(" }");
|
|
328
|
+
lines.push("");
|
|
329
|
+
lines.push(" async exportTasks(input: ExportTasksInput): Promise<ExportTasksResult> {");
|
|
330
|
+
lines.push(" const jobId = crypto.randomUUID();");
|
|
331
|
+
lines.push(" const submittedAt = new Date();");
|
|
332
|
+
lines.push(" const statusUrl = `/task-exports/${jobId}`;");
|
|
333
|
+
lines.push(' const archive = new TextEncoder().encode(JSON.stringify({ exported_at: submittedAt.toISOString(), filters: input }, null, 2));');
|
|
334
|
+
lines.push(" this.exportJobs.set(jobId, {");
|
|
335
|
+
lines.push(" job_id: jobId,");
|
|
336
|
+
lines.push(' status: "accepted",');
|
|
337
|
+
lines.push(" status_url: statusUrl,");
|
|
338
|
+
lines.push(" submitted_at: submittedAt.toISOString(),");
|
|
339
|
+
lines.push(" archive");
|
|
340
|
+
lines.push(" });");
|
|
341
|
+
lines.push(" queueMicrotask(() => {");
|
|
342
|
+
lines.push(" const existing = this.exportJobs.get(jobId);");
|
|
343
|
+
lines.push(" if (!existing) return;");
|
|
344
|
+
lines.push(" this.exportJobs.set(jobId, {");
|
|
345
|
+
lines.push(" ...existing,");
|
|
346
|
+
lines.push(' status: "completed",');
|
|
347
|
+
lines.push(" completed_at: new Date().toISOString(),");
|
|
348
|
+
lines.push(' download_url: `${statusUrl}/download`');
|
|
349
|
+
lines.push(" });");
|
|
350
|
+
lines.push(" });");
|
|
351
|
+
lines.push(' return { job_id: jobId, status: "accepted", status_url: statusUrl, submitted_at: submittedAt.toISOString() };');
|
|
352
|
+
lines.push(" }");
|
|
353
|
+
lines.push("");
|
|
354
|
+
lines.push(" async getTaskExportJob(input: GetTaskExportJobInput): Promise<GetTaskExportJobResult> {");
|
|
355
|
+
lines.push(" const job = this.exportJobs.get(input.job_id);");
|
|
356
|
+
lines.push(' if (!job) throw new HttpError(404, "cap_get_task_export_job_not_found", "Export job not found");');
|
|
357
|
+
lines.push(" return {");
|
|
358
|
+
lines.push(" job_id: job.job_id,");
|
|
359
|
+
lines.push(" status: job.status,");
|
|
360
|
+
lines.push(" status_url: job.status_url,");
|
|
361
|
+
lines.push(" submitted_at: job.submitted_at,");
|
|
362
|
+
lines.push(" completed_at: job.completed_at,");
|
|
363
|
+
lines.push(" expires_at: job.expires_at,");
|
|
364
|
+
lines.push(" download_url: job.download_url,");
|
|
365
|
+
lines.push(" error_message: job.error_message");
|
|
366
|
+
lines.push(" };");
|
|
367
|
+
lines.push(" }");
|
|
368
|
+
lines.push("");
|
|
369
|
+
lines.push(" async downloadTaskExport(input: DownloadTaskExportInput): Promise<DownloadTaskExportResult> {");
|
|
370
|
+
lines.push(" const job = this.exportJobs.get(input.job_id);");
|
|
371
|
+
lines.push(' if (!job) throw new HttpError(404, "cap_download_task_export_not_found", "Export job not found");');
|
|
372
|
+
lines.push(' if (job.status !== "completed" || !job.download_url) throw new HttpError(409, "cap_download_task_export_not_ready", "Export job is not ready");');
|
|
373
|
+
lines.push(` return { body: job.archive, contentType: "${exportContentType}", filename: "${exportFilename}" };`);
|
|
374
|
+
lines.push(" }");
|
|
375
|
+
lines.push("");
|
|
376
|
+
lines.push(` async markExportJobCompleted(input: ${markInputType}): Promise<${markOutputType}> {`);
|
|
377
|
+
lines.push(" const job = this.exportJobs.get(input.job_id);");
|
|
378
|
+
lines.push(' if (!job) throw new HttpError(404, "cap_get_task_export_job_not_found", "Export job not found");');
|
|
379
|
+
lines.push(" this.exportJobs.set(input.job_id, {");
|
|
380
|
+
lines.push(" ...job,");
|
|
381
|
+
lines.push(' status: input.state as "accepted" | "running" | "completed" | "failed" | "expired",');
|
|
382
|
+
lines.push(' completed_at: input.state === "completed" ? new Date().toISOString() : job.completed_at,');
|
|
383
|
+
lines.push(" download_url: input.download_url ?? job.download_url,");
|
|
384
|
+
lines.push(" error_message: input.error_message");
|
|
385
|
+
lines.push(" });");
|
|
386
|
+
lines.push(' return { job_id: input.job_id, state: input.state as "accepted" | "running" | "completed" | "failed" | "expired" };');
|
|
387
|
+
lines.push(" }");
|
|
388
|
+
lines.push("}");
|
|
389
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function buildDrizzleRepositoryBody({
|
|
393
|
+
repositoryInterfaceName,
|
|
394
|
+
drizzleRepositoryClassName,
|
|
395
|
+
drizzleHint
|
|
396
|
+
}) {
|
|
397
|
+
const markInputType = "MarkExportJobCompletedInput";
|
|
398
|
+
const markOutputType = "MarkExportJobCompletedResult";
|
|
399
|
+
const lines = [];
|
|
400
|
+
lines.push(`export class ${drizzleRepositoryClassName} implements ${repositoryInterfaceName} {`);
|
|
401
|
+
lines.push(" constructor(private readonly db: NodePgDatabase<Record<string, never>>) {}");
|
|
402
|
+
lines.push("");
|
|
403
|
+
for (const method of [
|
|
404
|
+
"listProjectOptions(): Promise<LookupOption[]>",
|
|
405
|
+
"listUserOptions(): Promise<LookupOption[]>",
|
|
406
|
+
"getProject(input: GetProjectInput): Promise<GetProjectResult>",
|
|
407
|
+
"listProjects(input: ListProjectsInput): Promise<ListProjectsResult>",
|
|
408
|
+
"createProject(input: CreateProjectInput): Promise<CreateProjectResult>",
|
|
409
|
+
"updateProject(input: UpdateProjectInput): Promise<UpdateProjectResult>",
|
|
410
|
+
"getUser(input: GetUserInput): Promise<GetUserResult>",
|
|
411
|
+
"listUsers(input: ListUsersInput): Promise<ListUsersResult>",
|
|
412
|
+
"createUser(input: CreateUserInput): Promise<CreateUserResult>",
|
|
413
|
+
"updateUser(input: UpdateUserInput): Promise<UpdateUserResult>",
|
|
414
|
+
"getTask(input: GetTaskInput): Promise<GetTaskResult>",
|
|
415
|
+
"listTasks(input: ListTasksInput): Promise<ListTasksResult>",
|
|
416
|
+
"createTask(input: CreateTaskInput): Promise<CreateTaskResult>",
|
|
417
|
+
"updateTask(input: UpdateTaskInput): Promise<UpdateTaskResult>",
|
|
418
|
+
"completeTask(input: CompleteTaskInput): Promise<CompleteTaskResult>",
|
|
419
|
+
"deleteTask(input: DeleteTaskInput): Promise<DeleteTaskResult>",
|
|
420
|
+
"exportTasks(input: ExportTasksInput): Promise<ExportTasksResult>",
|
|
421
|
+
"getTaskExportJob(input: GetTaskExportJobInput): Promise<GetTaskExportJobResult>",
|
|
422
|
+
"downloadTaskExport(input: DownloadTaskExportInput): Promise<DownloadTaskExportResult>",
|
|
423
|
+
`markExportJobCompleted(input: ${markInputType}): Promise<${markOutputType}>`
|
|
424
|
+
]) {
|
|
425
|
+
const methodName = method.slice(0, method.indexOf("("));
|
|
426
|
+
lines.push(` async ${method} {`);
|
|
427
|
+
lines.push(" void this.db;");
|
|
428
|
+
lines.push(` throw new Error("${methodName} is not implemented yet. ${drizzleHint}");`);
|
|
429
|
+
lines.push(" }");
|
|
430
|
+
lines.push("");
|
|
431
|
+
}
|
|
432
|
+
lines.push("}");
|
|
433
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function renderTodoPrismaRepositoryBody(args) {
|
|
437
|
+
return buildPrismaRepositoryBody(args);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function renderTodoDrizzleRepositoryBody(args) {
|
|
441
|
+
return buildDrizzleRepositoryBody(args);
|
|
442
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { TODO_BACKEND_REFERENCE } from "./backend/reference.js";
|
|
2
|
+
import { TODO_BACKEND_REPOSITORY_REFERENCE } from "./backend/repository-reference.js";
|
|
3
|
+
import {
|
|
4
|
+
renderTodoDrizzleRepositoryBody,
|
|
5
|
+
renderTodoPrismaRepositoryBody
|
|
6
|
+
} from "./backend/repository-renderers.js";
|
|
7
|
+
import { TODO_RUNTIME_REFERENCE } from "./runtime/reference.js";
|
|
8
|
+
import { TODO_RUNTIME_CHECKS } from "./runtime/checks.js";
|
|
9
|
+
import {
|
|
10
|
+
renderTodoRuntimeCheckCases,
|
|
11
|
+
renderTodoRuntimeCheckCreatePayload,
|
|
12
|
+
renderTodoRuntimeCheckHelpers,
|
|
13
|
+
renderTodoRuntimeCheckState
|
|
14
|
+
} from "./runtime/check-renderers.js";
|
|
15
|
+
import { TODO_WEB_REFERENCE } from "./web/reference.js";
|
|
16
|
+
import { TODO_WEB_SCREEN_REFERENCE } from "./web/screens-reference.js";
|
|
17
|
+
import {
|
|
18
|
+
renderTodoHomePage,
|
|
19
|
+
renderTodoTaskRoutes
|
|
20
|
+
} from "./web/renderers.js";
|
|
21
|
+
|
|
22
|
+
export const WEB_API_DB_IMPLEMENTATION = {
|
|
23
|
+
exampleId: "web-api-db-template",
|
|
24
|
+
exampleRoot: "/topogram",
|
|
25
|
+
backend: {
|
|
26
|
+
reference: TODO_BACKEND_REFERENCE,
|
|
27
|
+
repositoryReference: TODO_BACKEND_REPOSITORY_REFERENCE,
|
|
28
|
+
repositoryRenderers: {
|
|
29
|
+
renderPrismaRepositoryBody: renderTodoPrismaRepositoryBody,
|
|
30
|
+
renderDrizzleRepositoryBody: renderTodoDrizzleRepositoryBody
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
runtime: {
|
|
34
|
+
reference: TODO_RUNTIME_REFERENCE,
|
|
35
|
+
checks: TODO_RUNTIME_CHECKS,
|
|
36
|
+
checkRenderers: {
|
|
37
|
+
renderRuntimeCheckState: renderTodoRuntimeCheckState,
|
|
38
|
+
renderRuntimeCheckCreatePayload: renderTodoRuntimeCheckCreatePayload,
|
|
39
|
+
renderRuntimeCheckHelpers: renderTodoRuntimeCheckHelpers,
|
|
40
|
+
renderRuntimeCheckCases: renderTodoRuntimeCheckCases
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
web: {
|
|
44
|
+
reference: TODO_WEB_REFERENCE,
|
|
45
|
+
screenReference: TODO_WEB_SCREEN_REFERENCE,
|
|
46
|
+
renderers: {
|
|
47
|
+
renderHomePage: renderTodoHomePage,
|
|
48
|
+
renderRoutes: renderTodoTaskRoutes
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export default WEB_API_DB_IMPLEMENTATION;
|