Compare commits

..

8 Commits

Author SHA1 Message Date
Henry
be8795e34f fix: use debian to build migration image 2026-02-19 10:58:52 +00:00
Henry
cbf347dce0 fix: use correct org.opencontainers.image.version label 2026-02-18 23:21:36 +00:00
Henry
cb9736134d feat: publish migrator image 2026-02-18 23:15:19 +00:00
Henry
05a0245988 feat: improve pricing tiers and add comparison table (#405) 2026-02-18 22:46:32 +00:00
Matt
b6b9423823 fix: increase max workspace url length from 24 to 64 characters (closes #379( (#398) 2026-02-17 13:40:58 +00:00
Owais Rizvi
1696aab43b fix: handle empty commenter name in mention notification emails (#400)
Use `||` instead of `??` so empty strings also fall back to email.
The nullish coalescing operator (`??`) only catches null/undefined,
so users with an empty name string would appear nameless in emails.
2026-02-17 13:39:24 +00:00
Nick Meinhold
33d2f23955 feat(db): add webhook schema, migration, and repository (#391)
* feat(db): add webhook schema, migration, and repository

Add the database foundation for workspace webhooks:

- Add workspace_webhooks table with migration (webhook_event enum,
  URL, secret, event subscriptions, active flag)
- Add webhook repository with CRUD operations
- Add webhooks schema definition with relations
- Extend card and list repos to return board/list names for
  webhook payload context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(db): remove unused webhook_event enum and fix migration timestamp

- Remove dead webhook_event pgEnum from schema (events column uses text)
- Remove CREATE TYPE statement from migration SQL
- Fix migration journal timestamp to be chronologically after the
  notifications migration (idx 25)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(db): return boardPublicId from card and list repo queries

Add board publicId to getWorkspaceAndCardIdByCardPublicId and
getWorkspaceAndListIdByListPublicId return values, needed for
correct boardId in webhook payloads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(db): add workspaceId index and document secret exposure

- Add index on workspaceId for efficient webhook lookups per workspace
- Add JSDoc comment on getActiveByWorkspaceId explaining that it
  returns secrets for server-side HMAC signing only and must never
  be exposed via client-facing endpoints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(db): extract parseEvents helper in webhook repo

DRY up 6 repeated JSON.parse-and-cast calls into a single helper
function, addressing reviewer feedback on PR #391.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 13:23:00 +00:00
Morfixx
961219835e refactor: docker image for less final size (#385) 2026-02-17 13:12:53 +00:00
20 changed files with 578 additions and 99 deletions

View File

@@ -1,18 +1,62 @@
# Environment
.env
.env.*
!.env.example
docker-compose.override.yml
Dockerfile
./**/*/Dockerfile
# Docker
docker-compose*.yml
.dockerignore
# Dependencies (rebuilt in Docker)
node_modules
./**/*/node_modules
**/node_modules
# Build outputs (rebuilt in Docker)
**/.next
**/dist
**/out
**/.turbo
**/.cache
# Git
.git
.gitignore
.gitattributes
# IDE
.vscode
.idea
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# CI/CD
.github
.husky
.changeset
# Documentation (not needed in build)
*.md
!packages/db/migrations/**
LICENSE
CONTRIBUTING.md
AGENTS.md
CHANGELOG.md
# Test files
**/*.test.ts
**/*.test.tsx
**/*.spec.ts
**/*.spec.tsx
**/__tests__
**/coverage
# Logs
pnpm-debug.log
./**/*/pnpm-debug.log
**/pnpm-debug.log
README.md
.next
# .git
# Cloud compose (separate deployment)
cloud/

View File

@@ -61,21 +61,6 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
# Extract version from git tag or ref
# Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234
- name: Extract version
@@ -112,6 +97,39 @@ jobs:
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
labels: |
org.opencontainers.image.version=${{ steps.version.outputs.version }}
# Extract metadata for migrate image
- name: Extract Docker metadata for migrate
id: meta-migrate
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-migrate
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
labels: |
org.opencontainers.image.version=${{ steps.version.outputs.version }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
@@ -129,6 +147,24 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
# Build and push migrate Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push migrate Docker image
id: build-and-push-migrate
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
with:
context: .
file: apps/web/Dockerfile
target: migrate
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta-migrate.outputs.tags }}
labels: ${{ steps.meta-migrate.outputs.labels }}
build-args: |
APP_VERSION=${{ steps.version.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker
# repository is public to avoid leaking data. If you would like to publish
@@ -143,3 +179,11 @@ jobs:
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
# Sign the migrate Docker image digest except on PRs.
- name: Sign the published migrate Docker image
if: ${{ github.event_name != 'pull_request' }}
env:
TAGS: ${{ steps.meta-migrate.outputs.tags }}
DIGEST: ${{ steps.build-and-push-migrate.outputs.digest }}
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}

View File

@@ -1,25 +1,23 @@
ARG NODE_VERSION=20
ARG APP_DIRNAME=web
ARG PROJECT=@kan/web
# syntax=docker/dockerfile:1.7
# 1. Alpine image
ARG NODE_VERSION=20
ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs${NODE_VERSION}-debian12
# ============================================
# Stage 1: Alpine base with pnpm and turbo
# ============================================
FROM node:${NODE_VERSION}-alpine AS alpine
RUN apk update && \
apk add --no-cache --virtual .build-deps libc6-compat python3 make g++ && \
rm -rf /var/cache/apk/* /tmp/* || true
apk add --no-cache libc6-compat && \
rm -rf /var/cache/apk/* /tmp/* || true && \
corepack enable && \
npm install turbo@2.3.1 --global && \
pnpm config set store-dir ~/.pnpm-store
# Setup pnpm and turbo on the alpine base
FROM alpine AS base
RUN corepack enable
# Replace <your-major-version> with the major version installed in your repository. For example:
# RUN npm install turbo@2.1.3 --global
RUN npm install turbo@2.3.1 --global
RUN pnpm config set store-dir ~/.pnpm-store
# 2. Prune projects
FROM base AS pruner
ARG PROJECT
# ============================================
# Stage 2: Prune the monorepo
# ============================================
FROM alpine AS pruner
RUN apk add --no-cache git
@@ -35,53 +33,92 @@ RUN git fetch --tags --unshallow 2>/dev/null || git fetch --tags 2>/dev/null ||
echo "unknown") && \
echo "$AUTO_VERSION" > /app/AUTO_VERSION
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
# 3. Build the project
FROM base AS builder
ARG PROJECT
ARG APP_VERSION
RUN turbo prune --scope=@kan/web --scope=@kan/db --docker
# ============================================
# Stage 3: Install dependencies
# ============================================
FROM alpine AS deps
WORKDIR /app
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION
ENV CI=true
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
COPY --from=pruner /app/out/full/ .
# Use provided APP_VERSION or auto-generated from pruner stage
RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=${PROJECT}
# # Copy static files to standalone directory
# RUN mkdir -p apps/web/.next/standalone/.next && \
# mv apps/web/public apps/web/.next/standalone/ && \
# mv apps/web/.next/static apps/web/.next/standalone/.next/
# 4. Final image - runner stage to run the application
FROM base AS runner
ARG APP_DIRNAME
# Don't run production as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs
# ============================================
# Stage 4: Build the web application
# ============================================
FROM alpine AS builder
ARG APP_VERSION
WORKDIR /app
COPY --from=deps /app/ ./
COPY --from=pruner /app/out/full/ .
COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION
# Force standalone output for production Docker image
ENV NEXT_PUBLIC_USE_STANDALONE_OUTPUT=true
ENV CI=true
RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=@kan/web
# ============================================
# Stage 5: Migration image (run-once container)
# Note: Use a Debian-based Node image instead of Alpine here because
# Alpine under QEMU has caused Illegal instruction (signal 4) crashes
# during npm installs on arm64 multi-arch builds.
# ============================================
FROM node:${NODE_VERSION}-bullseye-slim AS migrate
WORKDIR /db
COPY packages/db/drizzle.config.ts ./drizzle.config.ts
COPY packages/db/migrations/ ./migrations/
RUN npm init -y && \
npm install drizzle-kit drizzle-orm pg --save-exact && \
# Strip unnecessary files from node_modules
find node_modules -type f \( \
-name '*.d.ts' -o \
-name '*.d.mts' -o \
-name '*.d.cts' -o \
-name '*.map' -o \
-name '*.md' -o \
-name '*.txt' -o \
-name 'LICENSE*' -o \
-name 'CHANGELOG*' -o \
-name 'README*' -o \
-name '.eslint*' -o \
-name '.prettier*' -o \
-name 'tsconfig*.json' \
\) -delete && \
find node_modules -type d -empty -delete && \
rm -rf /root/.npm /tmp/*
CMD ["npx", "drizzle-kit", "migrate"]
# ============================================
# Stage 6: Production web image (distroless)
# ============================================
FROM ${DISTROLESS_NODE_IMAGE} AS web
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
COPY --chown=nextjs:nodejs --from=builder /app/ ./
WORKDIR /app/apps/${APP_DIRNAME}
# Copy the standalone Next.js server
COPY --from=builder /app/apps/web/.next/standalone/ ./
# Copy static assets and public files
COPY --from=builder /app/apps/web/.next/static/ ./apps/web/.next/static/
COPY --from=builder /app/apps/web/public/ ./apps/web/public/
ARG PORT=3000
ENV PORT=${PORT}
EXPOSE ${PORT}
# Copy bootstrap script for runtime env var injection
COPY apps/web/bootstrap.cjs ./bootstrap.cjs
CMD ["sh", "-c", "if [ -n \"$POSTGRES_URL\" ]; then cd /app && pnpm db:migrate && cd /app/apps/web; fi && pnpm start"]
EXPOSE 3000
CMD ["bootstrap.cjs"]

44
apps/web/bootstrap.cjs Normal file
View File

@@ -0,0 +1,44 @@
/**
* Bootstrap script for the distroless production image.
*
* Distroless images have no shell, so this Node.js script handles two tasks
* that would normally be done in an entrypoint.sh:
*
* 1. Regenerate `public/__ENV.js` with the current runtime NEXT_PUBLIC_*
* environment variables. The file was originally created at build time by
* next-runtime-env's `configureRuntimeEnv()`, but in a Docker deployment the
* env vars are provided at *run* time via docker-compose / docker run.
*
* 2. Start the Next.js standalone server.
*/
const { writeFileSync, existsSync, mkdirSync } = require("fs");
const path = require("path");
// ---------------------------------------------------------------------------
// 1. Inject runtime NEXT_PUBLIC_* env vars into __ENV.js
// ---------------------------------------------------------------------------
const publicDir = path.join(__dirname, "apps", "web", "public");
if (!existsSync(publicDir)) {
mkdirSync(publicDir, { recursive: true });
}
const envVars = {};
for (const [key, value] of Object.entries(process.env)) {
if (key.startsWith("NEXT_PUBLIC_")) {
envVars[key] = value;
}
}
writeFileSync(
path.join(publicDir, "__ENV.js"),
`self.__ENV = ${JSON.stringify(envVars)};`,
);
// ---------------------------------------------------------------------------
// 2. Start the Next.js standalone server
// ---------------------------------------------------------------------------
require("./apps/web/server.js");

View File

@@ -16,6 +16,18 @@ const config = {
: undefined,
reactStrictMode: true,
/** Exclude build tools and dev-only packages from the standalone output */
outputFileTracingExcludes: {
"**/*": [
"@esbuild/**",
"esbuild/**",
"typescript/**",
"webpack/**",
"uglify-js/**",
"terser/**",
],
},
/** Enables hot reloading for local packages without a build step */
transpilePackages: [
"@kan/api",
@@ -51,8 +63,8 @@ const config = {
hostname: "*.googleusercontent.com",
},
{
protocol: 'https',
hostname: 'cdn.discordapp.com',
protocol: "https",
hostname: "cdn.discordapp.com",
},
];

View File

@@ -33,7 +33,7 @@ const schema = z.object({
.min(3, {
message: t`URL must be at least 3 characters long`,
})
.max(24, { message: t`URL cannot exceed 24 characters` })
.max(64, { message: t`URL cannot exceed 64 characters` })
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
message: t`URL can only contain letters, numbers, and hyphens`,
})

View File

@@ -11,7 +11,7 @@ import { withRateLimit } from "@kan/api/utils/rateLimit";
const workspaceSlugSchema = z
.string()
.min(3)
.max(24)
.max(64)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/);
interface CheckoutSessionRequest {

View File

@@ -33,7 +33,7 @@ const UpdateWorkspaceUrlForm = ({
.min(3, {
message: t`URL must be at least 3 characters long`,
})
.max(24, { message: t`URL cannot exceed 24 characters` })
.max(64, { message: t`URL cannot exceed 64 characters` })
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
message: t`URL can only contain letters, numbers, and hyphens`,
}),

View File

@@ -1,4 +1,20 @@
services:
migrate:
image: ghcr.io/kanbn/kan-migrate:latest
container_name: ${CONTAINER_NAME:-kan-migrate}
networks:
- kan-network
build:
context: .
dockerfile: ./apps/web/Dockerfile
target: migrate
environment:
- POSTGRES_URL=${POSTGRES_URL}
depends_on:
postgres:
condition: service_healthy
restart: "no"
web:
image: ghcr.io/kanbn/kan:latest
container_name: ${CONTAINER_NAME:-kan-web}
@@ -6,10 +22,10 @@ services:
- "${WEB_PORT:-3000}:3000"
networks:
- kan-network
- dokploy-network
build:
context: .
dockerfile: ./apps/web/Dockerfile.new
dockerfile: ./apps/web/Dockerfile
target: web
env_file:
- .env
environment:
@@ -107,7 +123,8 @@ services:
- APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET}
- APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER}
depends_on:
- postgres
migrate:
condition: service_completed_successfully
restart: unless-stopped
postgres:
@@ -121,14 +138,17 @@ services:
- 5432:5432
volumes:
- kan_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U kan -d kan_db"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
networks:
- kan-network
networks:
kan-network:
dokploy-network:
external: true
volumes:
kan_postgres_data:

View File

@@ -218,7 +218,7 @@ export const boardRouter = createTRPCRouter({
workspaceSlug: z
.string()
.min(3)
.max(24)
.max(64)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
boardSlug: z
.string()

View File

@@ -160,7 +160,7 @@ export const workspaceRouter = createTRPCRouter({
workspaceSlug: z
.string()
.min(3)
.max(24)
.max(64)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
}),
)
@@ -207,7 +207,7 @@ export const workspaceRouter = createTRPCRouter({
slug: z
.string()
.min(3)
.max(24)
.max(64)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
.optional(),
}),
@@ -290,7 +290,7 @@ export const workspaceRouter = createTRPCRouter({
slug: z
.string()
.min(3)
.max(24)
.max(64)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
.optional(),
description: z.string().min(3).max(280).optional(),
@@ -424,7 +424,7 @@ export const workspaceRouter = createTRPCRouter({
workspaceSlug: z
.string()
.min(3)
.max(24)
.max(64)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
}),
)

View File

@@ -53,7 +53,7 @@ export async function sendMentionEmails({
const commenter = await userRepo.getById(db, commenterUserId);
if (!commenter) return;
const commenterName = commenter.name ?? commenter.email;
const commenterName = commenter.name?.trim() || commenter.email;
// Get mentioned members with full details (filtered by workspace)
const membersWithDetails = await memberRepo.getByPublicIdsWithUsers(

View File

@@ -12,15 +12,13 @@ import { configuredProviders } from "./providers";
export const initAuth = (db: dbClient) => {
const baseURL = env("NEXT_PUBLIC_BASE_URL") || env("BETTER_AUTH_URL");
const trustedOrigins = env("BETTER_AUTH_TRUSTED_ORIGINS")?.split(",") ?? [];
const trustedOrigins =
env("BETTER_AUTH_TRUSTED_ORIGINS")?.split(",").filter(Boolean) ?? [];
return betterAuth({
secret: env("BETTER_AUTH_SECRET"),
baseURL,
trustedOrigins: [
...(baseURL ? [baseURL] : []),
...trustedOrigins,
],
trustedOrigins: [...(baseURL ? [baseURL] : []), ...trustedOrigins],
database: drizzleAdapter(db, {
provider: "pg",
schema: {

View File

@@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS "workspace_webhooks" (
"id" bigserial PRIMARY KEY NOT NULL,
"publicId" varchar(12) NOT NULL,
"workspaceId" bigint NOT NULL,
"name" varchar(255) NOT NULL,
"url" varchar(2048) NOT NULL,
"secret" text,
"events" text NOT NULL,
"active" boolean DEFAULT true NOT NULL,
"createdBy" uuid NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp,
CONSTRAINT "workspace_webhooks_publicId_unique" UNIQUE("publicId")
);
--> statement-breakpoint
ALTER TABLE "workspace_webhooks" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "workspace_webhooks_workspace_idx" ON "workspace_webhooks" USING btree ("workspaceId");--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_webhooks" ADD CONSTRAINT "workspace_webhooks_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_webhooks" ADD CONSTRAINT "workspace_webhooks_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -190,6 +190,13 @@
"when": 1770521594167,
"tag": "20260208033314_AddAttachmentToActivity",
"breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1771192000000,
"tag": "20260129210000_AddWorkspaceWebhooks",
"breakpoints": true
}
]
}

View File

@@ -945,12 +945,14 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
where: and(eq(cards.publicId, cardPublicId), isNull(cards.deletedAt)),
with: {
list: {
columns: {},
columns: { name: true },
with: {
board: {
columns: {
publicId: true,
workspaceId: true,
visibility: true,
name: true,
},
},
},
@@ -964,6 +966,9 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
createdBy: result.createdBy,
workspaceId: result.list.board.workspaceId,
workspaceVisibility: result.list.board.visibility,
listName: result.list.name,
boardPublicId: result.list.board.publicId,
boardName: result.list.board.name,
}
: null;
};

View File

@@ -419,12 +419,14 @@ export const getWorkspaceAndListIdByListPublicId = async (
listPublicId: string,
) => {
const result = await db.query.lists.findFirst({
columns: { id: true, createdBy: true },
columns: { id: true, name: true, createdBy: true },
where: and(eq(lists.publicId, listPublicId), isNull(lists.deletedAt)),
with: {
board: {
columns: {
publicId: true,
workspaceId: true,
name: true,
},
},
},
@@ -433,8 +435,11 @@ export const getWorkspaceAndListIdByListPublicId = async (
return result
? {
id: result.id,
name: result.name,
createdBy: result.createdBy,
workspaceId: result.board.workspaceId,
boardPublicId: result.board.publicId,
boardName: result.board.name,
}
: null;
};

View File

@@ -0,0 +1,175 @@
import { and, eq } from "drizzle-orm";
import type { dbClient } from "@kan/db/client";
import { workspaceWebhooks } from "@kan/db/schema";
import { generateUID } from "@kan/shared/utils";
import type { WebhookEvent } from "../schema/webhooks";
/** Parse JSON-encoded events column into typed array */
function parseEvents(raw: string): WebhookEvent[] {
return JSON.parse(raw) as WebhookEvent[];
}
export const create = async (
db: dbClient,
webhookInput: {
workspaceId: number;
name: string;
url: string;
secret?: string;
events: WebhookEvent[];
createdBy: string;
},
) => {
const [webhook] = await db
.insert(workspaceWebhooks)
.values({
publicId: generateUID(),
workspaceId: webhookInput.workspaceId,
name: webhookInput.name,
url: webhookInput.url,
secret: webhookInput.secret,
events: JSON.stringify(webhookInput.events),
createdBy: webhookInput.createdBy,
})
.returning({
publicId: workspaceWebhooks.publicId,
name: workspaceWebhooks.name,
url: workspaceWebhooks.url,
events: workspaceWebhooks.events,
active: workspaceWebhooks.active,
createdAt: workspaceWebhooks.createdAt,
});
return webhook
? {
...webhook,
events: parseEvents(webhook.events),
}
: null;
};
export const update = async (
db: dbClient,
webhookPublicId: string,
webhookInput: {
name?: string;
url?: string;
secret?: string;
events?: WebhookEvent[];
active?: boolean;
},
) => {
const [result] = await db
.update(workspaceWebhooks)
.set({
name: webhookInput.name,
url: webhookInput.url,
secret: webhookInput.secret,
events: webhookInput.events
? JSON.stringify(webhookInput.events)
: undefined,
active: webhookInput.active,
updatedAt: new Date(),
})
.where(eq(workspaceWebhooks.publicId, webhookPublicId))
.returning({
publicId: workspaceWebhooks.publicId,
name: workspaceWebhooks.name,
url: workspaceWebhooks.url,
events: workspaceWebhooks.events,
active: workspaceWebhooks.active,
createdAt: workspaceWebhooks.createdAt,
updatedAt: workspaceWebhooks.updatedAt,
});
return result
? {
...result,
events: parseEvents(result.events),
}
: null;
};
export const getByPublicId = async (db: dbClient, webhookPublicId: string) => {
const result = await db.query.workspaceWebhooks.findFirst({
columns: {
id: true,
publicId: true,
workspaceId: true,
name: true,
url: true,
secret: true,
events: true,
active: true,
createdAt: true,
updatedAt: true,
},
where: eq(workspaceWebhooks.publicId, webhookPublicId),
});
return result
? {
...result,
events: parseEvents(result.events),
}
: null;
};
export const getAllByWorkspaceId = async (
db: dbClient,
workspaceId: number,
) => {
const results = await db.query.workspaceWebhooks.findMany({
columns: {
publicId: true,
name: true,
url: true,
events: true,
active: true,
createdAt: true,
updatedAt: true,
},
where: eq(workspaceWebhooks.workspaceId, workspaceId),
});
return results.map((webhook) => ({
...webhook,
events: parseEvents(webhook.events),
}));
};
/**
* Returns active webhooks with secrets for server-side delivery (HMAC signing).
* DO NOT expose this via tRPC or any client-facing endpoint.
* Use getAllByWorkspaceId (which omits secrets) for the admin list endpoint.
*/
export const getActiveByWorkspaceId = async (
db: dbClient,
workspaceId: number,
) => {
const results = await db.query.workspaceWebhooks.findMany({
columns: {
publicId: true,
url: true,
secret: true,
events: true,
},
where: and(
eq(workspaceWebhooks.workspaceId, workspaceId),
eq(workspaceWebhooks.active, true),
),
});
return results.map((webhook) => ({
...webhook,
events: parseEvents(webhook.events),
}));
};
export const hardDelete = (db: dbClient, webhookPublicId: string) => {
return db
.delete(workspaceWebhooks)
.where(eq(workspaceWebhooks.publicId, webhookPublicId));
};

View File

@@ -14,3 +14,4 @@ export * from "./subscriptions";
export * from "./workspaceInviteLinks";
export * from "./permissions";
export * from "./notifications";
export * from "./webhooks";

View File

@@ -0,0 +1,59 @@
import { relations } from "drizzle-orm";
import {
bigint,
bigserial,
boolean,
index,
pgTable,
text,
timestamp,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import { users } from "./users";
import { workspaces } from "./workspaces";
export const webhookEvents = [
"card.created",
"card.updated",
"card.moved",
"card.deleted",
] as const;
export type WebhookEvent = (typeof webhookEvents)[number];
export const workspaceWebhooks = pgTable("workspace_webhooks", {
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
workspaceId: bigint("workspaceId", { mode: "number" })
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
url: varchar("url", { length: 2048 }).notNull(),
secret: text("secret"),
events: text("events").notNull(), // JSON array of webhook events
active: boolean("active").notNull().default(true),
createdBy: uuid("createdBy")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
}, (table) => [
index("workspace_webhooks_workspace_idx").on(table.workspaceId),
]).enableRLS();
export const workspaceWebhooksRelations = relations(
workspaceWebhooks,
({ one }) => ({
workspace: one(workspaces, {
fields: [workspaceWebhooks.workspaceId],
references: [workspaces.id],
relationName: "workspaceWebhooksWorkspace",
}),
createdByUser: one(users, {
fields: [workspaceWebhooks.createdBy],
references: [users.id],
relationName: "workspaceWebhooksCreatedByUser",
}),
}),
);