Compare commits
1 Commits
feat/publi
...
feat/impro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac9bdaf316 |
@@ -1,62 +1,18 @@
|
|||||||
# Environment
|
|
||||||
.env
|
.env
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# Docker
|
docker-compose.override.yml
|
||||||
docker-compose*.yml
|
|
||||||
|
Dockerfile
|
||||||
|
./**/*/Dockerfile
|
||||||
|
|
||||||
.dockerignore
|
.dockerignore
|
||||||
|
|
||||||
# Dependencies (rebuilt in Docker)
|
|
||||||
node_modules
|
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
|
./**/*/pnpm-debug.log
|
||||||
|
|
||||||
# Cloud compose (separate deployment)
|
README.md
|
||||||
cloud/
|
.next
|
||||||
|
# .git
|
||||||
74
.github/workflows/docker-publish.yml
vendored
74
.github/workflows/docker-publish.yml
vendored
@@ -61,6 +61,21 @@ jobs:
|
|||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
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
|
# Extract version from git tag or ref
|
||||||
# Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234
|
# Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234
|
||||||
- name: Extract version
|
- name: Extract version
|
||||||
@@ -97,39 +112,6 @@ jobs:
|
|||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "Version: $VERSION"
|
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)
|
# Build and push Docker image with Buildx (don't push on PR)
|
||||||
# https://github.com/docker/build-push-action
|
# https://github.com/docker/build-push-action
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
@@ -147,24 +129,6 @@ jobs:
|
|||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
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.
|
# Sign the resulting Docker image digest except on PRs.
|
||||||
# This will only write to the public Rekor transparency log when the Docker
|
# 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
|
# repository is public to avoid leaking data. If you would like to publish
|
||||||
@@ -179,11 +143,3 @@ jobs:
|
|||||||
# This step uses the identity token to provision an ephemeral certificate
|
# This step uses the identity token to provision an ephemeral certificate
|
||||||
# against the sigstore community Fulcio instance.
|
# against the sigstore community Fulcio instance.
|
||||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
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}
|
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
|
||||||
|
|
||||||
ARG NODE_VERSION=20
|
ARG NODE_VERSION=20
|
||||||
ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs${NODE_VERSION}-debian12
|
ARG APP_DIRNAME=web
|
||||||
|
ARG PROJECT=@kan/web
|
||||||
|
|
||||||
# ============================================
|
# 1. Alpine image
|
||||||
# Stage 1: Alpine base with pnpm and turbo
|
|
||||||
# ============================================
|
|
||||||
FROM node:${NODE_VERSION}-alpine AS alpine
|
FROM node:${NODE_VERSION}-alpine AS alpine
|
||||||
RUN apk update && \
|
RUN apk update && \
|
||||||
apk add --no-cache libc6-compat && \
|
apk add --no-cache --virtual .build-deps libc6-compat python3 make g++ && \
|
||||||
rm -rf /var/cache/apk/* /tmp/* || true && \
|
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
|
||||||
# Stage 2: Prune the monorepo
|
FROM alpine AS base
|
||||||
# ============================================
|
RUN corepack enable
|
||||||
FROM alpine AS pruner
|
# 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
|
||||||
|
|
||||||
RUN apk add --no-cache git
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
@@ -33,92 +35,53 @@ RUN git fetch --tags --unshallow 2>/dev/null || git fetch --tags 2>/dev/null ||
|
|||||||
echo "unknown") && \
|
echo "unknown") && \
|
||||||
echo "$AUTO_VERSION" > /app/AUTO_VERSION
|
echo "$AUTO_VERSION" > /app/AUTO_VERSION
|
||||||
|
|
||||||
RUN turbo prune --scope=@kan/web --scope=@kan/db --docker
|
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
|
||||||
|
|
||||||
|
# 3. Build the project
|
||||||
|
FROM base AS builder
|
||||||
|
ARG PROJECT
|
||||||
|
ARG APP_VERSION
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# Stage 3: Install dependencies
|
|
||||||
# ============================================
|
|
||||||
FROM alpine AS deps
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
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/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||||
COPY --from=pruner /app/out/json/ .
|
COPY --from=pruner /app/out/json/ .
|
||||||
|
|
||||||
ENV CI=true
|
|
||||||
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# 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
|
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
|
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')}" && \
|
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
|
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=${PROJECT}
|
||||||
|
|
||||||
# ============================================
|
# # Copy static files to standalone directory
|
||||||
# Stage 5: Migration image (run-once container)
|
# RUN mkdir -p apps/web/.next/standalone/.next && \
|
||||||
# Note: Use a Debian-based Node image instead of Alpine here because
|
# mv apps/web/public apps/web/.next/standalone/ && \
|
||||||
# Alpine under QEMU has caused Illegal instruction (signal 4) crashes
|
# mv apps/web/.next/static apps/web/.next/standalone/.next/
|
||||||
# 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
|
# 4. Final image - runner stage to run the application
|
||||||
COPY packages/db/migrations/ ./migrations/
|
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
|
||||||
|
|
||||||
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
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
|
||||||
ENV HOSTNAME=0.0.0.0
|
|
||||||
|
|
||||||
# Copy the standalone Next.js server
|
COPY --chown=nextjs:nodejs --from=builder /app/ ./
|
||||||
COPY --from=builder /app/apps/web/.next/standalone/ ./
|
WORKDIR /app/apps/${APP_DIRNAME}
|
||||||
# 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/
|
|
||||||
|
|
||||||
# Copy bootstrap script for runtime env var injection
|
ARG PORT=3000
|
||||||
COPY apps/web/bootstrap.cjs ./bootstrap.cjs
|
ENV PORT=${PORT}
|
||||||
|
EXPOSE ${PORT}
|
||||||
|
|
||||||
EXPOSE 3000
|
CMD ["sh", "-c", "if [ -n \"$POSTGRES_URL\" ]; then cd /app && pnpm db:migrate && cd /app/apps/web; fi && pnpm start"]
|
||||||
CMD ["bootstrap.cjs"]
|
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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");
|
|
||||||
@@ -16,18 +16,6 @@ const config = {
|
|||||||
: undefined,
|
: undefined,
|
||||||
reactStrictMode: true,
|
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 */
|
/** Enables hot reloading for local packages without a build step */
|
||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
"@kan/api",
|
"@kan/api",
|
||||||
@@ -63,8 +51,8 @@ const config = {
|
|||||||
hostname: "*.googleusercontent.com",
|
hostname: "*.googleusercontent.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
protocol: "https",
|
protocol: 'https',
|
||||||
hostname: "cdn.discordapp.com",
|
hostname: 'cdn.discordapp.com',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const schema = z.object({
|
|||||||
.min(3, {
|
.min(3, {
|
||||||
message: t`URL must be at least 3 characters long`,
|
message: t`URL must be at least 3 characters long`,
|
||||||
})
|
})
|
||||||
.max(64, { message: t`URL cannot exceed 64 characters` })
|
.max(24, { message: t`URL cannot exceed 24 characters` })
|
||||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
|
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
|
||||||
message: t`URL can only contain letters, numbers, and hyphens`,
|
message: t`URL can only contain letters, numbers, and hyphens`,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { withRateLimit } from "@kan/api/utils/rateLimit";
|
|||||||
const workspaceSlugSchema = z
|
const workspaceSlugSchema = z
|
||||||
.string()
|
.string()
|
||||||
.min(3)
|
.min(3)
|
||||||
.max(64)
|
.max(24)
|
||||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/);
|
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/);
|
||||||
|
|
||||||
interface CheckoutSessionRequest {
|
interface CheckoutSessionRequest {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const UpdateWorkspaceUrlForm = ({
|
|||||||
.min(3, {
|
.min(3, {
|
||||||
message: t`URL must be at least 3 characters long`,
|
message: t`URL must be at least 3 characters long`,
|
||||||
})
|
})
|
||||||
.max(64, { message: t`URL cannot exceed 64 characters` })
|
.max(24, { message: t`URL cannot exceed 24 characters` })
|
||||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
|
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
|
||||||
message: t`URL can only contain letters, numbers, and hyphens`,
|
message: t`URL can only contain letters, numbers, and hyphens`,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,20 +1,4 @@
|
|||||||
services:
|
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:
|
web:
|
||||||
image: ghcr.io/kanbn/kan:latest
|
image: ghcr.io/kanbn/kan:latest
|
||||||
container_name: ${CONTAINER_NAME:-kan-web}
|
container_name: ${CONTAINER_NAME:-kan-web}
|
||||||
@@ -22,10 +6,10 @@ services:
|
|||||||
- "${WEB_PORT:-3000}:3000"
|
- "${WEB_PORT:-3000}:3000"
|
||||||
networks:
|
networks:
|
||||||
- kan-network
|
- kan-network
|
||||||
|
- dokploy-network
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./apps/web/Dockerfile
|
dockerfile: ./apps/web/Dockerfile.new
|
||||||
target: web
|
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
@@ -123,8 +107,7 @@ services:
|
|||||||
- APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET}
|
- APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET}
|
||||||
- APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER}
|
- APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER}
|
||||||
depends_on:
|
depends_on:
|
||||||
migrate:
|
- postgres
|
||||||
condition: service_completed_successfully
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
@@ -138,17 +121,14 @@ services:
|
|||||||
- 5432:5432
|
- 5432:5432
|
||||||
volumes:
|
volumes:
|
||||||
- kan_postgres_data:/var/lib/postgresql/data
|
- 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
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- kan-network
|
- kan-network
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
kan-network:
|
kan-network:
|
||||||
|
dokploy-network:
|
||||||
|
external: true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
kan_postgres_data:
|
kan_postgres_data:
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
workspaceSlug: z
|
workspaceSlug: z
|
||||||
.string()
|
.string()
|
||||||
.min(3)
|
.min(3)
|
||||||
.max(64)
|
.max(24)
|
||||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
||||||
boardSlug: z
|
boardSlug: z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
workspaceSlug: z
|
workspaceSlug: z
|
||||||
.string()
|
.string()
|
||||||
.min(3)
|
.min(3)
|
||||||
.max(64)
|
.max(24)
|
||||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -207,7 +207,7 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
slug: z
|
slug: z
|
||||||
.string()
|
.string()
|
||||||
.min(3)
|
.min(3)
|
||||||
.max(64)
|
.max(24)
|
||||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
|
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
|
||||||
.optional(),
|
.optional(),
|
||||||
}),
|
}),
|
||||||
@@ -290,7 +290,7 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
slug: z
|
slug: z
|
||||||
.string()
|
.string()
|
||||||
.min(3)
|
.min(3)
|
||||||
.max(64)
|
.max(24)
|
||||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
|
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
|
||||||
.optional(),
|
.optional(),
|
||||||
description: z.string().min(3).max(280).optional(),
|
description: z.string().min(3).max(280).optional(),
|
||||||
@@ -424,7 +424,7 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
workspaceSlug: z
|
workspaceSlug: z
|
||||||
.string()
|
.string()
|
||||||
.min(3)
|
.min(3)
|
||||||
.max(64)
|
.max(24)
|
||||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export async function sendMentionEmails({
|
|||||||
const commenter = await userRepo.getById(db, commenterUserId);
|
const commenter = await userRepo.getById(db, commenterUserId);
|
||||||
if (!commenter) return;
|
if (!commenter) return;
|
||||||
|
|
||||||
const commenterName = commenter.name?.trim() || commenter.email;
|
const commenterName = commenter.name ?? commenter.email;
|
||||||
|
|
||||||
// Get mentioned members with full details (filtered by workspace)
|
// Get mentioned members with full details (filtered by workspace)
|
||||||
const membersWithDetails = await memberRepo.getByPublicIdsWithUsers(
|
const membersWithDetails = await memberRepo.getByPublicIdsWithUsers(
|
||||||
|
|||||||
@@ -12,13 +12,15 @@ import { configuredProviders } from "./providers";
|
|||||||
|
|
||||||
export const initAuth = (db: dbClient) => {
|
export const initAuth = (db: dbClient) => {
|
||||||
const baseURL = env("NEXT_PUBLIC_BASE_URL") || env("BETTER_AUTH_URL");
|
const baseURL = env("NEXT_PUBLIC_BASE_URL") || env("BETTER_AUTH_URL");
|
||||||
const trustedOrigins =
|
const trustedOrigins = env("BETTER_AUTH_TRUSTED_ORIGINS")?.split(",") ?? [];
|
||||||
env("BETTER_AUTH_TRUSTED_ORIGINS")?.split(",").filter(Boolean) ?? [];
|
|
||||||
|
|
||||||
return betterAuth({
|
return betterAuth({
|
||||||
secret: env("BETTER_AUTH_SECRET"),
|
secret: env("BETTER_AUTH_SECRET"),
|
||||||
baseURL,
|
baseURL,
|
||||||
trustedOrigins: [...(baseURL ? [baseURL] : []), ...trustedOrigins],
|
trustedOrigins: [
|
||||||
|
...(baseURL ? [baseURL] : []),
|
||||||
|
...trustedOrigins,
|
||||||
|
],
|
||||||
database: drizzleAdapter(db, {
|
database: drizzleAdapter(db, {
|
||||||
provider: "pg",
|
provider: "pg",
|
||||||
schema: {
|
schema: {
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
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 $$;
|
|
||||||
@@ -190,13 +190,6 @@
|
|||||||
"when": 1770521594167,
|
"when": 1770521594167,
|
||||||
"tag": "20260208033314_AddAttachmentToActivity",
|
"tag": "20260208033314_AddAttachmentToActivity",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 27,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1771192000000,
|
|
||||||
"tag": "20260129210000_AddWorkspaceWebhooks",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -945,14 +945,12 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
|
|||||||
where: and(eq(cards.publicId, cardPublicId), isNull(cards.deletedAt)),
|
where: and(eq(cards.publicId, cardPublicId), isNull(cards.deletedAt)),
|
||||||
with: {
|
with: {
|
||||||
list: {
|
list: {
|
||||||
columns: { name: true },
|
columns: {},
|
||||||
with: {
|
with: {
|
||||||
board: {
|
board: {
|
||||||
columns: {
|
columns: {
|
||||||
publicId: true,
|
|
||||||
workspaceId: true,
|
workspaceId: true,
|
||||||
visibility: true,
|
visibility: true,
|
||||||
name: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -966,9 +964,6 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
|
|||||||
createdBy: result.createdBy,
|
createdBy: result.createdBy,
|
||||||
workspaceId: result.list.board.workspaceId,
|
workspaceId: result.list.board.workspaceId,
|
||||||
workspaceVisibility: result.list.board.visibility,
|
workspaceVisibility: result.list.board.visibility,
|
||||||
listName: result.list.name,
|
|
||||||
boardPublicId: result.list.board.publicId,
|
|
||||||
boardName: result.list.board.name,
|
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -419,14 +419,12 @@ export const getWorkspaceAndListIdByListPublicId = async (
|
|||||||
listPublicId: string,
|
listPublicId: string,
|
||||||
) => {
|
) => {
|
||||||
const result = await db.query.lists.findFirst({
|
const result = await db.query.lists.findFirst({
|
||||||
columns: { id: true, name: true, createdBy: true },
|
columns: { id: true, createdBy: true },
|
||||||
where: and(eq(lists.publicId, listPublicId), isNull(lists.deletedAt)),
|
where: and(eq(lists.publicId, listPublicId), isNull(lists.deletedAt)),
|
||||||
with: {
|
with: {
|
||||||
board: {
|
board: {
|
||||||
columns: {
|
columns: {
|
||||||
publicId: true,
|
|
||||||
workspaceId: true,
|
workspaceId: true,
|
||||||
name: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -435,11 +433,8 @@ export const getWorkspaceAndListIdByListPublicId = async (
|
|||||||
return result
|
return result
|
||||||
? {
|
? {
|
||||||
id: result.id,
|
id: result.id,
|
||||||
name: result.name,
|
|
||||||
createdBy: result.createdBy,
|
createdBy: result.createdBy,
|
||||||
workspaceId: result.board.workspaceId,
|
workspaceId: result.board.workspaceId,
|
||||||
boardPublicId: result.board.publicId,
|
|
||||||
boardName: result.board.name,
|
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,175 +0,0 @@
|
|||||||
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));
|
|
||||||
};
|
|
||||||
@@ -14,4 +14,3 @@ export * from "./subscriptions";
|
|||||||
export * from "./workspaceInviteLinks";
|
export * from "./workspaceInviteLinks";
|
||||||
export * from "./permissions";
|
export * from "./permissions";
|
||||||
export * from "./notifications";
|
export * from "./notifications";
|
||||||
export * from "./webhooks";
|
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
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",
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
Reference in New Issue
Block a user