feat: card checklists (#141)

* feat: setup checklist schema

* feat: scaffold checklist router

* feat: add create new checklist modal

* feat: create new checklist item

* feat: toggle checklist item completed state

* feat: update checklist name

* feat: delete checklist

* feat: show checklists progress on cards

* feat: tweak light mode styling

* feat: focus item form on creation of new checklist

* feat: tweak new checklist item form styling

* feat: add checklist activity

* feat: show checklist progress on public board page

* feat: display checklist items on public card modal

* chore: add translations
This commit is contained in:
Henry
2025-08-14 19:59:36 +01:00
committed by GitHub
parent 80d1c5f805
commit 13b10471ec
45 changed files with 8171 additions and 684 deletions

View File

@@ -1,5 +1,6 @@
import { boardRouter } from "./routers/board";
import { cardRouter } from "./routers/card";
import { checklistRouter } from "./routers/checklist";
import { feedbackRouter } from "./routers/feedback";
import { importRouter } from "./routers/import";
import { integrationRouter } from "./routers/integration";
@@ -13,6 +14,7 @@ import { createTRPCRouter } from "./trpc";
export const appRouter = createTRPCRouter({
board: boardRouter,
card: cardRouter,
checklist: checklistRouter,
feedback: feedbackRouter,
label: labelRouter,
list: listRouter,

View File

@@ -0,0 +1,410 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import * as cardRepo from "@kan/db/repository/card.repo";
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
import * as checklistRepo from "@kan/db/repository/checklist.repo";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertUserInWorkspace } from "../utils/auth";
const checklistSchema = z.object({
publicId: z.string().length(12),
name: z.string().min(1).max(255),
});
const checklistItemSchema = z.object({
publicId: z.string().length(12),
title: z.string().min(1).max(500),
completed: z.boolean(),
});
export const checklistRouter = createTRPCRouter({
create: protectedProcedure
.meta({
openapi: {
summary: "Add a checklist to a card",
method: "POST",
path: "/cards/{cardPublicId}/checklists",
description: "Adds a checklist to a card",
tags: ["Cards"],
protect: true,
},
})
.input(
z.object({
cardPublicId: z.string().length(12),
name: z.string().min(1).max(255),
}),
)
.output(checklistSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
ctx.db,
input.cardPublicId,
);
if (!card)
throw new TRPCError({
message: `Card with public ID ${input.cardPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
const newChecklist = await checklistRepo.create(ctx.db, {
name: input.name,
createdBy: userId,
cardId: card.id,
});
if (!newChecklist?.id)
throw new TRPCError({
message: `Failed to create checklist`,
code: "INTERNAL_SERVER_ERROR",
});
await cardActivityRepo.create(ctx.db, {
type: "card.updated.checklist.added",
cardId: card.id,
toTitle: newChecklist.name,
createdBy: userId,
});
return newChecklist;
}),
update: protectedProcedure
.input(
z.object({
checklistPublicId: z.string().length(12),
name: z.string().min(1).max(255),
}),
)
.output(z.object({ publicId: z.string().length(12), name: z.string() }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const checklist = await checklistRepo.getChecklistByPublicId(
ctx.db,
input.checklistPublicId,
);
if (!checklist)
throw new TRPCError({
message: `Checklist with public ID ${input.checklistPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(
ctx.db,
userId,
checklist.card.list.board.workspace.id,
);
const previousName = checklist.name;
const updated = await checklistRepo.updateChecklistById(ctx.db, {
id: checklist.id,
name: input.name,
});
if (!updated)
throw new TRPCError({
message: `Failed to update checklist`,
code: "INTERNAL_SERVER_ERROR",
});
await cardActivityRepo.create(ctx.db, {
type: "card.updated.checklist.renamed",
cardId: checklist.cardId,
fromTitle: previousName,
toTitle: updated.name,
createdBy: userId,
});
return updated;
}),
delete: protectedProcedure
.meta({
openapi: {
summary: "Delete a checklist",
method: "DELETE",
path: "/checklists/{checklistPublicId}",
description: "Deletes a checklist by its public ID",
tags: ["Cards"],
protect: true,
},
})
.input(z.object({ checklistPublicId: z.string().length(12) }))
.output(z.object({ success: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const checklist = await checklistRepo.getChecklistByPublicId(
ctx.db,
input.checklistPublicId,
);
if (!checklist)
throw new TRPCError({
message: `Checklist with public ID ${input.checklistPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(
ctx.db,
userId,
checklist.card.list.board.workspace.id,
);
await checklistRepo.softDeleteAllItemsByChecklistId(ctx.db, {
checklistId: checklist.id,
deletedAt: new Date(),
deletedBy: userId,
});
const deleted = await checklistRepo.softDeleteById(ctx.db, {
id: checklist.id,
deletedAt: new Date(),
deletedBy: userId,
});
if (!deleted)
throw new TRPCError({
message: `Failed to delete checklist`,
code: "INTERNAL_SERVER_ERROR",
});
await cardActivityRepo.create(ctx.db, {
type: "card.updated.checklist.deleted",
cardId: checklist.cardId,
fromTitle: checklist.name,
createdBy: userId,
});
return { success: true };
}),
createItem: protectedProcedure
.meta({
openapi: {
summary: "Add an item to a checklist",
method: "POST",
path: "/checklists/{checklistPublicId}/items",
description: "Adds an item to a checklist",
tags: ["Cards"],
protect: true,
},
})
.input(
z.object({
checklistPublicId: z.string().length(12),
title: z.string().min(1).max(500),
}),
)
.output(checklistItemSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const checklist = await checklistRepo.getChecklistByPublicId(
ctx.db,
input.checklistPublicId,
);
if (!checklist)
throw new TRPCError({
message: `Checklist with public ID ${input.checklistPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(
ctx.db,
userId,
checklist.card.list.board.workspace.id,
);
const newChecklistItem = await checklistRepo.createItem(ctx.db, {
title: input.title,
createdBy: userId,
checklistId: checklist.id,
});
if (!newChecklistItem?.id)
throw new TRPCError({
message: `Failed to create checklist item`,
code: "INTERNAL_SERVER_ERROR",
});
await cardActivityRepo.create(ctx.db, {
type: "card.updated.checklist.item.added",
cardId: checklist.cardId,
toTitle: newChecklistItem.title,
createdBy: userId,
});
return newChecklistItem;
}),
updateItem: protectedProcedure
.meta({
openapi: {
summary: "Update a checklist item",
method: "PUT",
path: "/checklists/items/{checklistItemPublicId}",
description: "Updates a checklist item (title/completed)",
tags: ["Cards"],
protect: true,
},
})
.input(
z.object({
checklistItemPublicId: z.string().length(12),
title: z.string().min(1).max(500).optional(),
completed: z.boolean().optional(),
}),
)
.output(checklistItemSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const item = await checklistRepo.getChecklistItemByPublicIdWithChecklist(
ctx.db,
input.checklistItemPublicId,
);
if (!item)
throw new TRPCError({
message: `Checklist item with public ID ${input.checklistItemPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(
ctx.db,
userId,
item.checklist.card.list.board.workspace.id,
);
const previousTitle = item.title;
const updated = await checklistRepo.updateItemById(ctx.db, {
id: item.id,
title: input.title,
completed: input.completed,
});
if (!updated)
throw new TRPCError({
message: `Failed to update checklist item`,
code: "INTERNAL_SERVER_ERROR",
});
// Log completion toggle
if (input.completed !== undefined) {
await cardActivityRepo.create(ctx.db, {
type: input.completed
? "card.updated.checklist.item.completed"
: "card.updated.checklist.item.uncompleted",
cardId: item.checklist.cardId,
toTitle: updated.title,
createdBy: userId,
});
}
// Log title change
if (input.title !== undefined && input.title !== previousTitle) {
await cardActivityRepo.create(ctx.db, {
type: "card.updated.checklist.item.updated",
cardId: item.checklist.cardId,
fromTitle: previousTitle,
toTitle: updated.title,
createdBy: userId,
});
}
return updated;
}),
deleteItem: protectedProcedure
.meta({
openapi: {
summary: "Delete a checklist item",
method: "DELETE",
path: "/checklists/items/{checklistItemPublicId}",
description: "Deletes a checklist item",
tags: ["Cards"],
protect: true,
},
})
.input(z.object({ checklistItemPublicId: z.string().length(12) }))
.output(z.object({ success: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const item = await checklistRepo.getChecklistItemByPublicIdWithChecklist(
ctx.db,
input.checklistItemPublicId,
);
if (!item)
throw new TRPCError({
message: `Checklist item with public ID ${input.checklistItemPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(
ctx.db,
userId,
item.checklist.card.list.board.workspace.id,
);
const deleted = await checklistRepo.softDeleteItemById(ctx.db, {
id: item.id,
deletedAt: new Date(),
deletedBy: userId,
});
if (!deleted)
throw new TRPCError({
message: `Failed to delete item`,
code: "INTERNAL_SERVER_ERROR",
});
await cardActivityRepo.create(ctx.db, {
type: "card.updated.checklist.item.deleted",
cardId: item.checklist.cardId,
fromTitle: item.title,
createdBy: userId,
});
return { success: true };
}),
});

View File

@@ -0,0 +1,84 @@
CREATE TABLE IF NOT EXISTS "card_checklist_item" (
"id" bigserial PRIMARY KEY NOT NULL,
"publicId" varchar(12) NOT NULL,
"title" varchar(500) NOT NULL,
"completed" boolean DEFAULT false NOT NULL,
"index" integer NOT NULL,
"checklistId" bigint NOT NULL,
"createdBy" uuid,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp,
"deletedAt" timestamp,
"deletedBy" uuid,
CONSTRAINT "card_checklist_item_publicId_unique" UNIQUE("publicId")
);
--> statement-breakpoint
ALTER TABLE "card_checklist_item" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "card_checklist" (
"id" bigserial PRIMARY KEY NOT NULL,
"publicId" varchar(12) NOT NULL,
"name" varchar(255) NOT NULL,
"index" integer NOT NULL,
"cardId" bigint NOT NULL,
"createdBy" uuid,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp,
"deletedAt" timestamp,
"deletedBy" uuid,
CONSTRAINT "card_checklist_publicId_unique" UNIQUE("publicId")
);
--> statement-breakpoint
ALTER TABLE "card_checklist" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "integration" (
"provider" varchar(255) NOT NULL,
"userId" uuid NOT NULL,
"accessToken" varchar(255) NOT NULL,
"refreshToken" varchar(255),
"expiresAt" timestamp NOT NULL,
"createdAt" timestamp NOT NULL,
"updatedAt" timestamp,
CONSTRAINT "integration_pkey" PRIMARY KEY("userId","provider")
);
--> statement-breakpoint
ALTER TABLE "integration" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_checklist_item" ADD CONSTRAINT "card_checklist_item_checklistId_card_checklist_id_fk" FOREIGN KEY ("checklistId") REFERENCES "public"."card_checklist"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_checklist_item" ADD CONSTRAINT "card_checklist_item_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_checklist_item" ADD CONSTRAINT "card_checklist_item_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_checklist" ADD CONSTRAINT "card_checklist_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "public"."card"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_checklist" ADD CONSTRAINT "card_checklist_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_checklist" ADD CONSTRAINT "card_checklist_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "integration" ADD CONSTRAINT "integration_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -0,0 +1,8 @@
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.checklist.added' BEFORE 'card.archived';--> statement-breakpoint
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.checklist.renamed' BEFORE 'card.archived';--> statement-breakpoint
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.checklist.deleted' BEFORE 'card.archived';--> statement-breakpoint
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.checklist.item.added' BEFORE 'card.archived';--> statement-breakpoint
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.checklist.item.updated' BEFORE 'card.archived';--> statement-breakpoint
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.checklist.item.completed' BEFORE 'card.archived';--> statement-breakpoint
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.checklist.item.uncompleted' BEFORE 'card.archived';--> statement-breakpoint
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.checklist.item.deleted' BEFORE 'card.archived';

View File

@@ -1,6 +1,6 @@
{
"id": "91f9a2fa-31e2-4f3a-bdb9-852292fd7501",
"prevId": "058e8521-2814-46f0-a33f-1bbd86f450ce",
"prevId": "d170c8e0-bf75-4c71-abd9-0ecbeeb14003",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -92,13 +92,9 @@
"account_userId_user_id_fk": {
"name": "account_userId_user_id_fk",
"tableFrom": "account",
"columnsFrom": [
"userId"
],
"columnsFrom": ["userId"],
"tableTo": "user",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@@ -245,13 +241,9 @@
"apiKey_userId_user_id_fk": {
"name": "apiKey_userId_user_id_fk",
"tableFrom": "apiKey",
"columnsFrom": [
"userId"
],
"columnsFrom": ["userId"],
"tableTo": "user",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@@ -320,13 +312,9 @@
"session_userId_user_id_fk": {
"name": "session_userId_user_id_fk",
"tableFrom": "session",
"columnsFrom": [
"userId"
],
"columnsFrom": ["userId"],
"tableTo": "user",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@@ -335,9 +323,7 @@
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"columns": [
"token"
],
"columns": ["token"],
"nullsNotDistinct": false
}
},
@@ -523,52 +509,36 @@
"board_createdBy_user_id_fk": {
"name": "board_createdBy_user_id_fk",
"tableFrom": "board",
"columnsFrom": [
"createdBy"
],
"columnsFrom": ["createdBy"],
"tableTo": "user",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "set null"
},
"board_deletedBy_user_id_fk": {
"name": "board_deletedBy_user_id_fk",
"tableFrom": "board",
"columnsFrom": [
"deletedBy"
],
"columnsFrom": ["deletedBy"],
"tableTo": "user",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "set null"
},
"board_importId_import_id_fk": {
"name": "board_importId_import_id_fk",
"tableFrom": "board",
"columnsFrom": [
"importId"
],
"columnsFrom": ["importId"],
"tableTo": "import",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "no action"
},
"board_workspaceId_workspace_id_fk": {
"name": "board_workspaceId_workspace_id_fk",
"tableFrom": "board",
"columnsFrom": [
"workspaceId"
],
"columnsFrom": ["workspaceId"],
"tableTo": "workspace",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@@ -577,9 +547,7 @@
"uniqueConstraints": {
"board_publicId_unique": {
"name": "board_publicId_unique",
"columns": [
"publicId"
],
"columns": ["publicId"],
"nullsNotDistinct": false
}
},
@@ -713,91 +681,63 @@
"card_activity_cardId_card_id_fk": {
"name": "card_activity_cardId_card_id_fk",
"tableFrom": "card_activity",
"columnsFrom": [
"cardId"
],
"columnsFrom": ["cardId"],
"tableTo": "card",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
},
"card_activity_fromListId_list_id_fk": {
"name": "card_activity_fromListId_list_id_fk",
"tableFrom": "card_activity",
"columnsFrom": [
"fromListId"
],
"columnsFrom": ["fromListId"],
"tableTo": "list",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
},
"card_activity_toListId_list_id_fk": {
"name": "card_activity_toListId_list_id_fk",
"tableFrom": "card_activity",
"columnsFrom": [
"toListId"
],
"columnsFrom": ["toListId"],
"tableTo": "list",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
},
"card_activity_labelId_label_id_fk": {
"name": "card_activity_labelId_label_id_fk",
"tableFrom": "card_activity",
"columnsFrom": [
"labelId"
],
"columnsFrom": ["labelId"],
"tableTo": "label",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
},
"card_activity_workspaceMemberId_workspace_members_id_fk": {
"name": "card_activity_workspaceMemberId_workspace_members_id_fk",
"tableFrom": "card_activity",
"columnsFrom": [
"workspaceMemberId"
],
"columnsFrom": ["workspaceMemberId"],
"tableTo": "workspace_members",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "set null"
},
"card_activity_createdBy_user_id_fk": {
"name": "card_activity_createdBy_user_id_fk",
"tableFrom": "card_activity",
"columnsFrom": [
"createdBy"
],
"columnsFrom": ["createdBy"],
"tableTo": "user",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "set null"
},
"card_activity_commentId_card_comments_id_fk": {
"name": "card_activity_commentId_card_comments_id_fk",
"tableFrom": "card_activity",
"columnsFrom": [
"commentId"
],
"columnsFrom": ["commentId"],
"tableTo": "card_comments",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@@ -806,9 +746,7 @@
"uniqueConstraints": {
"card_activity_publicId_unique": {
"name": "card_activity_publicId_unique",
"columns": [
"publicId"
],
"columns": ["publicId"],
"nullsNotDistinct": false
}
},
@@ -838,26 +776,18 @@
"_card_workspace_members_cardId_card_id_fk": {
"name": "_card_workspace_members_cardId_card_id_fk",
"tableFrom": "_card_workspace_members",
"columnsFrom": [
"cardId"
],
"columnsFrom": ["cardId"],
"tableTo": "card",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
},
"_card_workspace_members_workspaceMemberId_workspace_members_id_fk": {
"name": "_card_workspace_members_workspaceMemberId_workspace_members_id_fk",
"tableFrom": "_card_workspace_members",
"columnsFrom": [
"workspaceMemberId"
],
"columnsFrom": ["workspaceMemberId"],
"tableTo": "workspace_members",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@@ -865,10 +795,7 @@
"compositePrimaryKeys": {
"_card_workspace_members_cardId_workspaceMemberId_pk": {
"name": "_card_workspace_members_cardId_workspaceMemberId_pk",
"columns": [
"cardId",
"workspaceMemberId"
]
"columns": ["cardId", "workspaceMemberId"]
}
},
"uniqueConstraints": {},
@@ -959,52 +886,36 @@
"card_createdBy_user_id_fk": {
"name": "card_createdBy_user_id_fk",
"tableFrom": "card",
"columnsFrom": [
"createdBy"
],
"columnsFrom": ["createdBy"],
"tableTo": "user",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "set null"
},
"card_deletedBy_user_id_fk": {
"name": "card_deletedBy_user_id_fk",
"tableFrom": "card",
"columnsFrom": [
"deletedBy"
],
"columnsFrom": ["deletedBy"],
"tableTo": "user",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "set null"
},
"card_listId_list_id_fk": {
"name": "card_listId_list_id_fk",
"tableFrom": "card",
"columnsFrom": [
"listId"
],
"columnsFrom": ["listId"],
"tableTo": "list",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
},
"card_importId_import_id_fk": {
"name": "card_importId_import_id_fk",
"tableFrom": "card",
"columnsFrom": [
"importId"
],
"columnsFrom": ["importId"],
"tableTo": "import",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "no action"
}
@@ -1013,9 +924,7 @@
"uniqueConstraints": {
"card_publicId_unique": {
"name": "card_publicId_unique",
"columns": [
"publicId"
],
"columns": ["publicId"],
"nullsNotDistinct": false
}
},
@@ -1045,26 +954,18 @@
"_card_labels_cardId_card_id_fk": {
"name": "_card_labels_cardId_card_id_fk",
"tableFrom": "_card_labels",
"columnsFrom": [
"cardId"
],
"columnsFrom": ["cardId"],
"tableTo": "card",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
},
"_card_labels_labelId_label_id_fk": {
"name": "_card_labels_labelId_label_id_fk",
"tableFrom": "_card_labels",
"columnsFrom": [
"labelId"
],
"columnsFrom": ["labelId"],
"tableTo": "label",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@@ -1072,10 +973,7 @@
"compositePrimaryKeys": {
"_card_labels_cardId_labelId_pk": {
"name": "_card_labels_cardId_labelId_pk",
"columns": [
"cardId",
"labelId"
]
"columns": ["cardId", "labelId"]
}
},
"uniqueConstraints": {},
@@ -1148,39 +1046,27 @@
"card_comments_cardId_card_id_fk": {
"name": "card_comments_cardId_card_id_fk",
"tableFrom": "card_comments",
"columnsFrom": [
"cardId"
],
"columnsFrom": ["cardId"],
"tableTo": "card",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
},
"card_comments_createdBy_user_id_fk": {
"name": "card_comments_createdBy_user_id_fk",
"tableFrom": "card_comments",
"columnsFrom": [
"createdBy"
],
"columnsFrom": ["createdBy"],
"tableTo": "user",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "set null"
},
"card_comments_deletedBy_user_id_fk": {
"name": "card_comments_deletedBy_user_id_fk",
"tableFrom": "card_comments",
"columnsFrom": [
"deletedBy"
],
"columnsFrom": ["deletedBy"],
"tableTo": "user",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "set null"
}
@@ -1189,9 +1075,7 @@
"uniqueConstraints": {
"card_comments_publicId_unique": {
"name": "card_comments_publicId_unique",
"columns": [
"publicId"
],
"columns": ["publicId"],
"nullsNotDistinct": false
}
},
@@ -1253,13 +1137,9 @@
"feedback_createdBy_user_id_fk": {
"name": "feedback_createdBy_user_id_fk",
"tableFrom": "feedback",
"columnsFrom": [
"createdBy"
],
"columnsFrom": ["createdBy"],
"tableTo": "user",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "set null"
}
@@ -1319,13 +1199,9 @@
"import_createdBy_user_id_fk": {
"name": "import_createdBy_user_id_fk",
"tableFrom": "import",
"columnsFrom": [
"createdBy"
],
"columnsFrom": ["createdBy"],
"tableTo": "user",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "set null"
}
@@ -1334,9 +1210,7 @@
"uniqueConstraints": {
"import_publicId_unique": {
"name": "import_publicId_unique",
"columns": [
"publicId"
],
"columns": ["publicId"],
"nullsNotDistinct": false
}
},
@@ -1421,52 +1295,36 @@
"label_createdBy_user_id_fk": {
"name": "label_createdBy_user_id_fk",
"tableFrom": "label",
"columnsFrom": [
"createdBy"
],
"columnsFrom": ["createdBy"],
"tableTo": "user",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "set null"
},
"label_boardId_board_id_fk": {
"name": "label_boardId_board_id_fk",
"tableFrom": "label",
"columnsFrom": [
"boardId"
],
"columnsFrom": ["boardId"],
"tableTo": "board",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
},
"label_importId_import_id_fk": {
"name": "label_importId_import_id_fk",
"tableFrom": "label",
"columnsFrom": [
"importId"
],
"columnsFrom": ["importId"],
"tableTo": "import",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "no action"
},
"label_deletedBy_user_id_fk": {
"name": "label_deletedBy_user_id_fk",
"tableFrom": "label",
"columnsFrom": [
"deletedBy"
],
"columnsFrom": ["deletedBy"],
"tableTo": "user",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "set null"
}
@@ -1475,9 +1333,7 @@
"uniqueConstraints": {
"label_publicId_unique": {
"name": "label_publicId_unique",
"columns": [
"publicId"
],
"columns": ["publicId"],
"nullsNotDistinct": false
}
},
@@ -1562,52 +1418,36 @@
"list_createdBy_user_id_fk": {
"name": "list_createdBy_user_id_fk",
"tableFrom": "list",
"columnsFrom": [
"createdBy"
],
"columnsFrom": ["createdBy"],
"tableTo": "user",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "set null"
},
"list_deletedBy_user_id_fk": {
"name": "list_deletedBy_user_id_fk",
"tableFrom": "list",
"columnsFrom": [
"deletedBy"
],
"columnsFrom": ["deletedBy"],
"tableTo": "user",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "set null"
},
"list_boardId_board_id_fk": {
"name": "list_boardId_board_id_fk",
"tableFrom": "list",
"columnsFrom": [
"boardId"
],
"columnsFrom": ["boardId"],
"tableTo": "board",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
},
"list_importId_import_id_fk": {
"name": "list_importId_import_id_fk",
"tableFrom": "list",
"columnsFrom": [
"importId"
],
"columnsFrom": ["importId"],
"tableTo": "import",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "no action"
}
@@ -1616,9 +1456,7 @@
"uniqueConstraints": {
"list_publicId_unique": {
"name": "list_publicId_unique",
"columns": [
"publicId"
],
"columns": ["publicId"],
"nullsNotDistinct": false
}
},
@@ -1688,9 +1526,7 @@
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"columns": ["email"],
"nullsNotDistinct": false
}
},
@@ -1722,9 +1558,7 @@
"uniqueConstraints": {
"workspace_slugs_slug_unique": {
"name": "workspace_slugs_slug_unique",
"columns": [
"slug"
],
"columns": ["slug"],
"nullsNotDistinct": false
}
},
@@ -1818,39 +1652,27 @@
"workspace_members_userId_user_id_fk": {
"name": "workspace_members_userId_user_id_fk",
"tableFrom": "workspace_members",
"columnsFrom": [
"userId"
],
"columnsFrom": ["userId"],
"tableTo": "user",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "set null"
},
"workspace_members_workspaceId_workspace_id_fk": {
"name": "workspace_members_workspaceId_workspace_id_fk",
"tableFrom": "workspace_members",
"columnsFrom": [
"workspaceId"
],
"columnsFrom": ["workspaceId"],
"tableTo": "workspace",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
},
"workspace_members_deletedBy_user_id_fk": {
"name": "workspace_members_deletedBy_user_id_fk",
"tableFrom": "workspace_members",
"columnsFrom": [
"deletedBy"
],
"columnsFrom": ["deletedBy"],
"tableTo": "user",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "set null"
}
@@ -1859,9 +1681,7 @@
"uniqueConstraints": {
"workspace_members_publicId_unique": {
"name": "workspace_members_publicId_unique",
"columns": [
"publicId"
],
"columns": ["publicId"],
"nullsNotDistinct": false
}
},
@@ -1948,26 +1768,18 @@
"workspace_createdBy_user_id_fk": {
"name": "workspace_createdBy_user_id_fk",
"tableFrom": "workspace",
"columnsFrom": [
"createdBy"
],
"columnsFrom": ["createdBy"],
"tableTo": "user",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "set null"
},
"workspace_deletedBy_user_id_fk": {
"name": "workspace_deletedBy_user_id_fk",
"tableFrom": "workspace",
"columnsFrom": [
"deletedBy"
],
"columnsFrom": ["deletedBy"],
"tableTo": "user",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "set null"
}
@@ -1976,16 +1788,12 @@
"uniqueConstraints": {
"workspace_publicId_unique": {
"name": "workspace_publicId_unique",
"columns": [
"publicId"
],
"columns": ["publicId"],
"nullsNotDistinct": false
},
"workspace_slug_unique": {
"name": "workspace_slug_unique",
"columns": [
"slug"
],
"columns": ["slug"],
"nullsNotDistinct": false
}
},
@@ -1998,10 +1806,7 @@
"public.board_visibility": {
"name": "board_visibility",
"schema": "public",
"values": [
"private",
"public"
]
"values": ["private", "public"]
},
"public.card_activity_type": {
"name": "card_activity_type",
@@ -2025,53 +1830,32 @@
"public.source": {
"name": "source",
"schema": "public",
"values": [
"trello"
]
"values": ["trello"]
},
"public.status": {
"name": "status",
"schema": "public",
"values": [
"started",
"success",
"failed"
]
"values": ["started", "success", "failed"]
},
"public.role": {
"name": "role",
"schema": "public",
"values": [
"admin",
"member",
"guest"
]
"values": ["admin", "member", "guest"]
},
"public.member_status": {
"name": "member_status",
"schema": "public",
"values": [
"invited",
"active",
"removed"
]
"values": ["invited", "active", "removed"]
},
"public.slug_type": {
"name": "slug_type",
"schema": "public",
"values": [
"reserved",
"premium"
]
"values": ["reserved", "premium"]
},
"public.workspace_plan": {
"name": "workspace_plan",
"schema": "public",
"values": [
"free",
"pro",
"enterprise"
]
"values": ["free", "pro", "enterprise"]
}
},
"schemas": {},
@@ -2084,4 +1868,4 @@
"schemas": {},
"tables": {}
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -50,6 +50,20 @@
"when": 1749585889984,
"tag": "20250610200449_ReaddIntegrationTable",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1754511666265,
"tag": "20250806202106_glossy_luminals",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1755094668761,
"tag": "20250813141748_AddChecklistActivityTypes",
"breakpoints": true
}
]
}
}

View File

@@ -7,6 +7,8 @@ import {
cards,
cardsToLabels,
cardToWorkspaceMembers,
checklistItems,
checklists,
labels,
lists,
workspaceMembers,
@@ -164,6 +166,27 @@ export const getByPublicId = async (
},
},
},
checklists: {
columns: {
publicId: true,
name: true,
index: true,
},
where: isNull(checklists.deletedAt),
orderBy: asc(checklists.index),
with: {
items: {
columns: {
publicId: true,
title: true,
completed: true,
index: true,
},
where: isNull(checklistItems.deletedAt),
orderBy: asc(checklistItems.index),
},
},
},
},
where: and(
cardIds.length > 0 ? inArray(cards.publicId, cardIds) : undefined,
@@ -280,6 +303,27 @@ export const getBySlug = async (
},
},
},
checklists: {
columns: {
publicId: true,
name: true,
index: true,
},
where: isNull(checklists.deletedAt),
orderBy: asc(checklists.index),
with: {
items: {
columns: {
publicId: true,
title: true,
completed: true,
index: true,
},
where: isNull(checklistItems.deletedAt),
orderBy: asc(checklistItems.index),
},
},
},
},
where: and(
cardIds.length > 0 ? inArray(cards.publicId, cardIds) : undefined,

View File

@@ -6,6 +6,8 @@ import {
cards,
cardsToLabels,
cardToWorkspaceMembers,
checklistItems,
checklists,
labels,
lists,
workspaceMembers,
@@ -300,6 +302,27 @@ export const getWithListAndMembersByPublicId = async (
},
},
},
checklists: {
columns: {
publicId: true,
name: true,
index: true,
},
where: isNull(checklists.deletedAt),
orderBy: asc(checklists.index),
with: {
items: {
columns: {
publicId: true,
title: true,
completed: true,
index: true,
},
where: isNull(checklistItems.deletedAt),
orderBy: asc(checklistItems.index),
},
},
},
list: {
columns: {
publicId: true,

View File

@@ -0,0 +1,216 @@
import { and, desc, eq, isNull } from "drizzle-orm";
import type { dbClient } from "@kan/db/client";
import { checklistItems, checklists } from "@kan/db/schema";
import { generateUID } from "@kan/shared/utils";
export const create = async (
db: dbClient,
checklistInput: {
cardId: number;
name: string;
createdBy: string;
},
) => {
return db.transaction(async (tx) => {
const card = await tx.query.checklists.findFirst({
where: and(
eq(checklists.cardId, checklistInput.cardId),
isNull(checklists.deletedAt),
),
orderBy: desc(checklists.index),
});
const [result] = await tx
.insert(checklists)
.values({
publicId: generateUID(),
name: checklistInput.name,
createdBy: checklistInput.createdBy,
cardId: checklistInput.cardId,
index: card ? card.index + 1 : 0,
})
.returning({
id: checklists.id,
publicId: checklists.publicId,
name: checklists.name,
});
return result;
});
};
export const createItem = async (
db: dbClient,
checklistItemInput: {
checklistId: number;
title: string;
createdBy: string;
},
) => {
return db.transaction(async (tx) => {
const lastItem = await tx.query.checklistItems.findFirst({
where: and(
eq(checklistItems.checklistId, checklistItemInput.checklistId),
isNull(checklistItems.deletedAt),
),
orderBy: desc(checklistItems.index),
});
const [result] = await tx
.insert(checklistItems)
.values({
publicId: generateUID(),
title: checklistItemInput.title,
createdBy: checklistItemInput.createdBy,
checklistId: checklistItemInput.checklistId,
index: lastItem ? lastItem.index + 1 : 0,
completed: false,
})
.returning({
id: checklistItems.id,
publicId: checklistItems.publicId,
title: checklistItems.title,
completed: checklistItems.completed,
});
return result;
});
};
export const getChecklistByPublicId = async (
db: dbClient,
checklistPublicId: string,
) => {
const checklist = await db.query.checklists.findFirst({
where: and(
eq(checklists.publicId, checklistPublicId),
isNull(checklists.deletedAt),
),
with: {
card: {
with: {
list: {
with: {
board: {
with: {
workspace: true,
},
},
},
},
},
},
},
});
return checklist;
};
export const getChecklistItemByPublicIdWithChecklist = async (
db: dbClient,
checklistItemPublicId: string,
) => {
const item = await db.query.checklistItems.findFirst({
where: and(
eq(checklistItems.publicId, checklistItemPublicId),
isNull(checklistItems.deletedAt),
),
with: {
checklist: {
with: {
card: {
with: {
list: {
with: {
board: {
with: { workspace: true },
},
},
},
},
},
},
},
},
});
return item;
};
export const updateItemById = async (
db: dbClient,
args: { id: number; title?: string; completed?: boolean },
) => {
const [result] = await db
.update(checklistItems)
.set({
...(args.title !== undefined ? { title: args.title } : {}),
...(args.completed !== undefined ? { completed: args.completed } : {}),
updatedAt: new Date(),
})
.where(eq(checklistItems.id, args.id))
.returning({
publicId: checklistItems.publicId,
title: checklistItems.title,
completed: checklistItems.completed,
});
return result;
};
export const softDeleteItemById = async (
db: dbClient,
args: { id: number; deletedAt: Date; deletedBy: string },
) => {
const [result] = await db
.update(checklistItems)
.set({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
.where(eq(checklistItems.id, args.id))
.returning({ id: checklistItems.id });
return result;
};
export const softDeleteAllItemsByChecklistId = async (
db: dbClient,
args: { checklistId: number; deletedAt: Date; deletedBy: string },
) => {
const result = await db
.update(checklistItems)
.set({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
.where(
and(
eq(checklistItems.checklistId, args.checklistId),
isNull(checklistItems.deletedAt),
),
)
.returning({ id: checklistItems.id });
return result;
};
export const softDeleteById = async (
db: dbClient,
args: { id: number; deletedAt: Date; deletedBy: string },
) => {
const [result] = await db
.update(checklists)
.set({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
.where(eq(checklists.id, args.id))
.returning({ id: checklists.id });
return result;
};
export const updateChecklistById = async (
db: dbClient,
args: { id: number; name: string },
) => {
const [result] = await db
.update(checklists)
.set({ name: args.name, updatedAt: new Date() })
.where(eq(checklists.id, args.id))
.returning({ publicId: checklists.publicId, name: checklists.name });
return result;
};

View File

@@ -12,6 +12,7 @@ import {
varchar,
} from "drizzle-orm/pg-core";
import { checklists } from "./checklists";
import { imports } from "./imports";
import { labels } from "./labels";
import { lists } from "./lists";
@@ -31,6 +32,15 @@ export const activityTypes = [
"card.updated.comment.added",
"card.updated.comment.updated",
"card.updated.comment.deleted",
// Checklist activities
"card.updated.checklist.added",
"card.updated.checklist.renamed",
"card.updated.checklist.deleted",
"card.updated.checklist.item.added",
"card.updated.checklist.item.updated",
"card.updated.checklist.item.completed",
"card.updated.checklist.item.uncompleted",
"card.updated.checklist.item.deleted",
"card.archived",
] as const;
@@ -84,6 +94,7 @@ export const cardsRelations = relations(cards, ({ one, many }) => ({
}),
comments: many(comments),
activities: many(cardActivities),
checklists: many(checklists),
}));
export const cardActivities = pgTable("card_activity", {

View File

@@ -0,0 +1,90 @@
import { relations } from "drizzle-orm";
import {
bigint,
bigserial,
boolean,
integer,
pgTable,
timestamp,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import { cards } from "./cards";
import { users } from "./users";
export const checklists = pgTable("card_checklist", {
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
name: varchar("name", { length: 255 }).notNull(),
index: integer("index").notNull(),
cardId: bigint("cardId", { mode: "number" })
.notNull()
.references(() => cards.id, { onDelete: "cascade" }),
createdBy: uuid("createdBy").references(() => users.id, {
onDelete: "set null",
}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id, {
onDelete: "set null",
}),
}).enableRLS();
export const checklistsRelations = relations(checklists, ({ one, many }) => ({
card: one(cards, {
fields: [checklists.cardId],
references: [cards.id],
relationName: "checklistsCard",
}),
createdBy: one(users, {
fields: [checklists.createdBy],
references: [users.id],
relationName: "checklistsCreatedByUser",
}),
deletedBy: one(users, {
fields: [checklists.deletedBy],
references: [users.id],
relationName: "checklistsDeletedByUser",
}),
items: many(checklistItems),
}));
export const checklistItems = pgTable("card_checklist_item", {
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
title: varchar("title", { length: 500 }).notNull(),
completed: boolean("completed").notNull().default(false),
index: integer("index").notNull(),
checklistId: bigint("checklistId", { mode: "number" })
.notNull()
.references(() => checklists.id, { onDelete: "cascade" }),
createdBy: uuid("createdBy").references(() => users.id, {
onDelete: "set null",
}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id, {
onDelete: "set null",
}),
}).enableRLS();
export const checklistItemsRelations = relations(checklistItems, ({ one }) => ({
checklist: one(checklists, {
fields: [checklistItems.checklistId],
references: [checklists.id],
relationName: "checklistItemsChecklist",
}),
createdBy: one(users, {
fields: [checklistItems.createdBy],
references: [users.id],
relationName: "checklistItemsCreatedByUser",
}),
deletedBy: one(users, {
fields: [checklistItems.deletedBy],
references: [users.id],
relationName: "checklistItemsDeletedByUser",
}),
}));

View File

@@ -2,6 +2,7 @@ export * from "./auth";
export * from "./boards";
export * from "./auth";
export * from "./cards";
export * from "./checklists";
export * from "./feedback";
export * from "./imports";
export * from "./labels";