Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions apps/api/src/controllers/template.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { Request, Response, RequestHandler } from "express";
import { asyncHandler } from "../utils/async-handler.js";
import prisma from "../lib/db.js";

// POST /api/v1/templates
export const createTemplate: RequestHandler = asyncHandler(
async (req: Request, res: Response) => {
const userId = res.locals.user.id as string;
const {
title,
description,
category,
iconSymbol,
images,
formId,
fields,
status,
} = req.body;

let resolvedFields = fields ?? [];
let finalFormId = null;

if (!fields && formId) {
const sourceForm = await prisma.form.findFirst({
where: { id: formId, userId },
select: { fields: true },
});

if (!sourceForm) {
res.status(404).json({
success: false,
message: "Source form not found or does not belong to you",
});
return;
}

resolvedFields = sourceForm.fields;
finalFormId = formId;
}

const template = await prisma.template.create({
data: {
title,
description,
category,
iconSymbol,
images: images ?? [],
fields: resolvedFields,
formId: finalFormId,
status: status ?? "PUBLISHED",
createdById: userId,
},
});
Comment thread
David-2610 marked this conversation as resolved.

res.status(201).json({ success: true, data: template });
}
);

// GET /api/v1/template

export const listTemplates: RequestHandler = asyncHandler(
async (req: Request, res: Response) => {
const { category } = req.query;
const take = Math.min(Number(req.query.limit) || 20, 50);
const skip = Math.max(Number(req.query.offset) || 0, 0);

const where: Record<string, unknown> = { status: "PUBLISHED" };
if (typeof category === "string" && category.length > 0) {
where.category = category;
}

const templates = await prisma.template.findMany({
where,
take,
skip,
orderBy: [
{ featured: "desc" },
{ useCount: "desc" },
{ createdAt: "desc" },
],
select: {
id: true,
title: true,
description: true,
category: true,
iconSymbol: true,
images: true,
featured: true,
useCount: true,
createdAt: true,
createdBy: {
select: {
id: true,
name: true,
image: true,
},
},
},
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

res.json({ success: true, data: templates });
}
);
25 changes: 25 additions & 0 deletions apps/api/src/lib/template-schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { z } from "zod";
import { FormDefinitionSchema } from "@repo/types";

export const CreateTemplateSchema = z.object({
title: z.string().min(1, "Title is required").max(200),
description: z.string().max(2000).optional(),
category: z.string().max(100).optional(),
iconSymbol: z.string().max(10).optional(),
images: z
.array(z.string().url("Each image must be a valid URL"))
.max(5, "Maximum 5 images allowed")
.default([]),

// Either supply a formId to snapshot from, or provide fields directly
formId: z.string().optional(),
fields: FormDefinitionSchema.optional(),
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Publish immediately or save as draft
status: z.enum(["DRAFT", "PUBLISHED"]).default("PUBLISHED"),
}).refine((d) => Boolean(d.formId) !== Boolean(d.fields), {
message: "Provide exactly one of `formId` or `fields`",
path: ["fields"],
});

export type CreateTemplateInput = z.infer<typeof CreateTemplateSchema>;
2 changes: 2 additions & 0 deletions apps/api/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import formRouter from "./form/form.routes";
import responseRouter from "./form/response.routes";
import publicRouter from "./public/public.routes";
import oauthRouter from "./user/oauth.routes";
import templateRouter from "./template/template.routes";
import { toNodeHandler } from "better-auth/node";
import { auth } from "../lib/auth";

Expand All @@ -22,5 +23,6 @@ router.use("/api/v1/admin/auth", adminAuthRouter);
router.use("/api/v1/forms", formRouter);
router.use("/api/v1/forms/:id/responses", responseRouter);
router.use("/api/v1/public", publicRouter);
router.use("/api/v1/templates", templateRouter);

export default router;
15 changes: 15 additions & 0 deletions apps/api/src/routes/template/template.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Router } from "express";
import { requireAuth } from "../../middleware/require-auth";
import { validate } from "../../middleware/validate";
import { CreateTemplateSchema } from "../../lib/template-schemas";
import {
createTemplate,
listTemplates,
} from "../../controllers/template.controller";

const templateRouter: Router = Router();

templateRouter.get("/", listTemplates);
templateRouter.post("/", requireAuth, validate(CreateTemplateSchema), createTemplate);

export default templateRouter;
50 changes: 34 additions & 16 deletions packages/db/prisma/schema/templates.prisma
Original file line number Diff line number Diff line change
@@ -1,27 +1,45 @@
// ============================================
// TEMPLATES
// ============================================
// ============================================
// TEMPLATES
// ============================================

enum TemplateStatus {
DRAFT
PUBLISHED
ARCHIVED
}

model Template {
id String @id @default(cuid())
id String @id @default(cuid())
title String
description String? @db.Text
description String? @db.Text
category String?

// Template structure (same format as Form.fields)
fields Json @default("[]")

fields Json @default("[]")
// Preview
iconSymbol String? @default("📋")

iconSymbol String? @default("📋")
images String[] @default([])

// Status
status TemplateStatus @default(DRAFT)

// Source form (optional back-reference)
formId String?

// Owner
createdById String?
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)

// Metadata
featured Boolean @default(false)
useCount Int @default(0)

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
featured Boolean @default(false)
useCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([featured])
@@index([category])
@@index([createdById])
@@index([status])
@@map("templates")
}
}
1 change: 1 addition & 0 deletions packages/db/prisma/schema/user.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ model User {
forms Form[]
sessions Session[]
accounts Account[]
templates Template[]

// Metadata
createdAt DateTime @default(now())
Expand Down
Loading