Compare commits

..

1 Commits

Author SHA1 Message Date
Henry
ac9bdaf316 feat: improve pricing tiers and add comparison table 2026-02-18 21:58:58 +00:00
144 changed files with 27596 additions and 84177 deletions

View File

@@ -1,62 +1,18 @@
# Environment
.env
.env.*
!.env.example
# Docker
docker-compose*.yml
docker-compose.override.yml
Dockerfile
./**/*/Dockerfile
.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
# Cloud compose (separate deployment)
cloud/
README.md
.next
# .git

View File

@@ -2,7 +2,7 @@
# Required environment variables
NEXT_PUBLIC_BASE_URL= # e.g. https://kan.bn
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 26 | tr -dc 'a-zA-Z0-9' | head -c 32)
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
# Fill if you want to use an external database
POSTGRES_URL= # e.g. postgresql://kan:your_password@your_host:5432/kan
@@ -28,14 +28,12 @@ S3_ENDPOINT=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_FORCE_PATH_STYLE=
# S3_AVATAR_UPLOAD_LIMIT=2097152 # Avatar upload size limit in bytes (default: 2MB)
NEXT_PUBLIC_STORAGE_URL=
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=
NEXT_PUBLIC_STORAGE_DOMAIN=
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=
# Auth config (optional)
NEXT_PUBLIC_ALLOW_CREDENTIALS=
NEXT_PUBLIC_DISABLE_SIGN_UP=
@@ -51,9 +49,6 @@ TRELLO_APP_SECRET=
# If not provided, rate limiting will use in-memory storage
REDIS_URL= # e.g. redis://default:your_password@your_host:6379
# Logging (optional - debug, info, warn, error; defaults to debug in dev, info in prod)
LOG_LEVEL=
# OAuth providers (optional)
BETTER_AUTH_TRUSTED_ORIGINS=
# Optional: Restrict OIDC/Social sign-ins to specific email domains (comma-separated)

View File

@@ -6,31 +6,23 @@ name: Docker
# documentation.
on:
schedule:
- cron: "21 21 * * *"
push:
# Only trigger on tags (releases), not main branch pushes
# Main branch builds are handled by workflow_run after Translate completes
branches: ["main"]
# Publish semver tags as releases.
tags: ["v*.*.*"]
pull_request:
branches: ["main"]
workflow_run:
workflows: ["Translate"]
types: [completed]
branches: [main]
# Docker builds after Translate completes (success or failure)
# This ensures Docker always has the latest translations
env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
MIGRATE_IMAGE_NAME: ${{ github.repository }}-migrate
jobs:
build:
concurrency:
group: docker-build-${{ github.ref }}
cancel-in-progress: true
runs-on: ubuntu-latest
permissions:
contents: read
@@ -82,22 +74,7 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable=${{ github.ref_type == 'tag' }}
type=raw,value=edge,enable={{is_default_branch}}
- name: Extract Docker metadata (migrate)
id: meta-migrate
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.MIGRATE_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=${{ github.ref_type == 'tag' }}
type=raw,value=edge,enable={{is_default_branch}}
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
@@ -152,22 +129,6 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
- 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
@@ -182,10 +143,3 @@ 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}
- 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,56 +0,0 @@
name: Translate
on:
push:
branches: [main]
permissions:
contents: write
jobs:
translate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 9
- name: Install dependencies
run: pnpm install --filter @kan/web...
- name: Extract translations
working-directory: apps/web
run: pnpm lingui:extract
- name: Lingo.dev
uses: lingodotdev/lingo.dev@main
with:
api-key: ${{ secrets.LINGODOTDEV_API_KEY }}
commit-message: "chore: update translations"
working-directory: apps/web
commit-author-name: "${{ github.actor }}"
commit-author-email: "${{ github.actor }}@users.noreply.github.com"
parallel: true
- name: Compile translations
working-directory: apps/web
run: pnpm lingui:compile
- name: Commit compiled translations
run: |
git config --local user.email "${{ github.actor }}@users.noreply.github.com"
git config --local user.name "${{ github.actor }}"
if ls apps/web/src/locales/*/messages.ts 1> /dev/null 2>&1; then
git add apps/web/src/locales/*/messages.ts
git diff --staged --quiet || (git commit -m "chore: compile translations" && git push)
fi

View File

@@ -185,17 +185,9 @@ Use `assertUserInWorkspace` helper for workspace checks.
- Use TRPCError with appropriate codes (UNAUTHORIZED, NOT_FOUND, etc.)
- Provide user-friendly error messages
- Log errors appropriately using the `@kan/logger` package
- Log errors appropriately
- Show popup notifications for user-facing errors
### Logging
- Import from `@kan/logger`: `import { createLogger } from "@kan/logger"`
- Create a module-scoped logger: `const logger = createLogger("module-name")`
- Log level is controlled by `LOG_LEVEL` env var (debug, info, warn, error)
- Defaults to `debug` in development, `info` in production
- Never use `console.log` — always use the logger
## Common Patterns
### Creating a Card
@@ -227,16 +219,6 @@ Use `assertUserInWorkspace` helper for workspace checks.
5. **Frontend**: Add UI components in `apps/web/src/`
6. **i18n**: Add translations for new strings
## Adding a New Environment Variable
Update all of the following:
1. `.env.example` — add the variable with an empty value and a comment explaining it
2. `turbo.json` — add to `globalEnv` (or `globalPassThroughEnv` for CI/platform vars)
3. `docker-compose.yml` — add to the `web` service `environment` section
4. `cloud/docker-compose.yml` — add to the `web` service `environment` section
5. `README.md` — add a row to the Environment Variables table
## Database Changes
- Always create migrations (never modify existing migrations)

View File

@@ -57,61 +57,39 @@ The easiest way to deploy Kan is through Railway. We've partnered with Railway t
### Docker Compose
Alternatively, you can self-host Kan with Docker Compose. This will set up everything for you including your postgres database and automatically run migrations.
Alternatively, you can self-host Kan with Docker Compose. This will set up everything for you including your postgres database.
1. Create a `.env` file with your environment variables (see [Environment Variables](#environment-variables-) section below)
2. Use the provided `docker-compose.yml` file or create your own with the following configuration:
1. Create a new file called `docker-compose.yml` and paste the following configuration:
```yaml
services:
migrate:
image: ghcr.io/kanbn/kan-migrate:latest
container_name: kan-migrate
networks:
- kan-network
environment:
- POSTGRES_URL=${POSTGRES_URL}
depends_on:
postgres:
condition: service_healthy
restart: "no"
web:
image: ghcr.io/kanbn/kan:latest
container_name: kan-web
ports:
- "${WEB_PORT:-3000}:3000"
- "3000:3000"
networks:
- kan-network
env_file:
- .env
environment:
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
- POSTGRES_URL=${POSTGRES_URL}
- NEXT_PUBLIC_ALLOW_CREDENTIALS=true
NEXT_PUBLIC_BASE_URL: http://localhost:3000
BETTER_AUTH_SECRET: your_auth_secret
POSTGRES_URL: postgresql://kan:your_postgres_password@postgres:5432/kan_db
NEXT_PUBLIC_ALLOW_CREDENTIALS: true
depends_on:
migrate:
condition: service_completed_successfully
- postgres
restart: unless-stopped
postgres:
image: postgres:15
container_name: kan-db
environment:
- POSTGRES_DB=kan_db
- POSTGRES_USER=kan
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
POSTGRES_DB: kan_db
POSTGRES_USER: kan
POSTGRES_PASSWORD: your_postgres_password
ports:
- 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
@@ -123,23 +101,23 @@ volumes:
kan_postgres_data:
```
3. Start the containers in detached mode:
2. Start the containers in detached mode:
```bash
docker compose up -d
```
The `migrate` service will automatically run database migrations before the web service starts. The application will be available at http://localhost:3000 (or the port specified in `WEB_PORT`).
3. Access Kan at http://localhost:3000
**Managing containers:**
The application will be running in the background. You can manage the containers using these commands:
- To stop the containers: `docker compose down`
- To view logs: `docker compose logs -f`
- To view logs for a specific service: `docker compose logs -f web` or `docker compose logs -f migrate`
- To restart the containers: `docker compose restart`
- To rebuild after code changes: `docker compose up -d --build`
For the complete Docker Compose configuration with all optional features, see [docker-compose.yml](./docker-compose.yml) in the repository.
For the complete Docker Compose configuration, see [docker-compose.yml](./docker-compose.yml) in the repository.
> **Note**: The Docker Compose configuration shown above is a minimal example. For a complete setup with all features (email, OAuth, file uploads, etc.), you'll need to create a `.env` file with the required environment variables. See the Environment Variables section below for the full list of available options.
## Local Development 🧑‍💻
@@ -203,7 +181,6 @@ pnpm dev
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads (optional with IRSA) | `xxx` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads (optional with IRSA) | `xxx` |
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
| `S3_AVATAR_UPLOAD_LIMIT` | Maximum avatar file size in bytes | For file uploads | `2097152` (2MB) |
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
| `NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS` | Use virtual-hosted style URLs (bucket.domain.com) | For file uploads (optional) | `true` |
@@ -213,7 +190,6 @@ pnpm dev
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` |
| `KAN_ADMIN_API_KEY` | Admin API key for stats and admin endpoints | For admin/monitoring | `your-secret-admin-key` |
| `LOG_LEVEL` | Log verbosity level (debug, info, warn, error) | No (defaults to debug in dev, info in prod) | `info` |
See `.env.example` for a complete list of supported environment variables.

View File

@@ -1,23 +1,25 @@
# syntax=docker/dockerfile:1.7
ARG NODE_VERSION=20
ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs${NODE_VERSION}-debian12
ARG APP_DIRNAME=web
ARG PROJECT=@kan/web
# ============================================
# Stage 1: Alpine base with pnpm and turbo
# ============================================
# 1. Alpine image
FROM node:${NODE_VERSION}-alpine AS alpine
RUN apk update && \
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
apk add --no-cache --virtual .build-deps libc6-compat python3 make g++ && \
rm -rf /var/cache/apk/* /tmp/* || true
# ============================================
# Stage 2: Prune the monorepo
# ============================================
FROM alpine AS pruner
# 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
RUN apk add --no-cache git
@@ -33,89 +35,53 @@ 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=@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
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/ .
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
# Force standalone output for production Docker image
ENV NEXT_PUBLIC_USE_STANDALONE_OUTPUT=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')}" && \
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=@kan/web
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=${PROJECT}
# ============================================
# Stage 5: Migration image (run-once container)
# ============================================
FROM node:${NODE_VERSION}-alpine AS migrate
WORKDIR /db
# # 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/
COPY packages/db/drizzle.config.ts ./drizzle.config.ts
COPY packages/db/migrations/ ./migrations/
# 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
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 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/
COPY --chown=nextjs:nodejs --from=builder /app/ ./
WORKDIR /app/apps/${APP_DIRNAME}
# Copy bootstrap script for runtime env var injection
COPY apps/web/bootstrap.cjs ./bootstrap.cjs
ARG PORT=3000
ENV PORT=${PORT}
EXPOSE ${PORT}
EXPOSE 3000
CMD ["bootstrap.cjs"]
CMD ["sh", "-c", "if [ -n \"$POSTGRES_URL\" ]; then cd /app && pnpm db:migrate && cd /app/apps/web; fi && pnpm start"]

View File

@@ -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");

View File

@@ -1,12 +1,12 @@
{
"version": "1.10",
"version": 0,
"locale": {
"source": "en",
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
},
"buckets": {
"json": {
"include": ["src/locales/[locale]/messages.json"]
"po": {
"include": ["src/locales/[locale]/messages.po"]
}
},
"$schema": "https://lingo.dev/schema/i18n.json"

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,7 @@
import { defineConfig } from "@lingui/cli";
import { formatter } from "@lingui/format-json";
import type { LinguiConfig } from "@lingui/conf";
export default defineConfig({
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"],
const config: LinguiConfig = {
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl","pt-BR"],
sourceLocale: "en",
catalogs: [
{
@@ -12,5 +10,6 @@ export default defineConfig({
exclude: ["**/node_modules/**"],
},
],
format: formatter({ style: "lingui" }),
});
};
export default config;

View File

@@ -16,18 +16,6 @@ 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",
@@ -47,13 +35,49 @@ const config = {
remotePatterns: (() => {
/** @type {Array<{protocol: "http" | "https", hostname: string}>} */
const patterns = [
{ protocol: "https", hostname: "**" },
{
protocol: "https",
hostname:
env("S3_FORCE_PATH_STYLE") === "true"
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
},
{
protocol: "http",
hostname: "localhost",
},
{
protocol: "https",
hostname: "*.googleusercontent.com",
},
{
protocol: 'https',
hostname: 'cdn.discordapp.com',
},
];
// Extract root domain from S3_ENDPOINT and add wildcard pattern
const s3Endpoint = env("S3_ENDPOINT");
if (s3Endpoint) {
try {
const url = new URL(s3Endpoint);
const hostname = url.hostname;
const protocol = url.protocol.replace(":", "");
// Extract root domain (last 2 parts: e.g. cloudflarestorage.com)
const parts = hostname.split(".");
if (parts.length >= 2) {
const rootDomain = parts.slice(-2).join(".");
patterns.push({
protocol: protocol === "http" ? "http" : "https",
hostname: `*.${rootDomain}`,
});
}
} catch {
// If S3_ENDPOINT is not a valid URL, ignore it
}
}
return patterns;
})(),
},

View File

@@ -27,7 +27,6 @@
"@kan/api": "workspace:*",
"@kan/auth": "workspace:*",
"@kan/db": "workspace:^",
"@kan/logger": "workspace:^",
"@kan/shared": "workspace:^",
"@lingui/babel-preset-react": "^2.9.2",
"@lingui/conf": "^5.3.2",
@@ -80,7 +79,6 @@
"@kan/tailwind-config": "workspace:*",
"@kan/tsconfig": "workspace:*",
"@lingui/cli": "^5.3.2",
"@lingui/format-json": "^5.9.1",
"@lingui/loader": "^5.3.2",
"@lingui/swc-plugin": "^5.5.2",
"@svgr/webpack": "^8.1.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -1,39 +0,0 @@
{
"background_color": "#000000",
"categories": [
"business",
"productivity",
"utilities"
],
"description": "The open source Trello alternative.",
"display": "standalone",
"icons": [
{
"sizes": "512x512",
"src": "/icon-512.png",
"type": "image/png",
"purpose": "any"
}
],
"id": "/",
"screenshots": [
{
"sizes": "1624x1561",
"src": "/screenshot-wide.png",
"type": "image/png",
"form_factor": "wide"
},
{
"sizes": "1290x2796",
"src": "/screenshot-narrow.png",
"type": "image/png",
"form_factor": "narrow"
}
],
"orientation": "portrait-primary",
"name": "Kan.bn | The open source alternative to Trello",
"scope": "/",
"short_name": "Kan.bn",
"start_url": "/",
"theme_color": "#000000"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -145,6 +145,7 @@ export default function CommandPallette({
value={result}
className="cursor-pointer select-none px-4 py-3 data-[focus]:bg-light-200 hover:bg-light-200 focus:outline-none dark:data-[focus]:bg-dark-200 dark:hover:bg-dark-200"
onClick={() => {
console.log("clicked", url);
void router.push(url);
onClose();
setQuery("");

View File

@@ -17,14 +17,9 @@ import { twMerge } from "tailwind-merge";
interface DateSelectorProps {
selectedDate?: Date | null;
onDateSelect?: (date: Date | undefined) => void;
weekStartsOn?: 0 | 1 | 6;
}
const DateSelector = ({
selectedDate,
onDateSelect,
weekStartsOn = 1,
}: DateSelectorProps) => {
const DateSelector = ({ selectedDate, onDateSelect }: DateSelectorProps) => {
const [currentMonth, setCurrentMonth] = useState(() => {
return selectedDate ? startOfMonth(selectedDate) : startOfMonth(new Date());
});
@@ -33,18 +28,18 @@ const DateSelector = ({
const year = format(currentMonth, "yyyy");
const dayHeaders = useMemo(() => {
const weekStart = startOfWeek(new Date(), { weekStartsOn });
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 }); // Monday
return eachDayOfInterval({
start: weekStart,
end: new Date(weekStart.getTime() + 6 * 24 * 60 * 60 * 1000),
}).map((date) => format(date, "EEEEEE")); // Shortest localized day name
}, [weekStartsOn]);
}, []);
const days = useMemo(() => {
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
const calendarStart = startOfWeek(monthStart, { weekStartsOn });
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn });
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Monday
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }); // Monday
return eachDayOfInterval({ start: calendarStart, end: calendarEnd }).map(
(date) => {
@@ -58,7 +53,7 @@ const DateSelector = ({
};
},
);
}, [currentMonth, selectedDate, weekStartsOn]);
}, [currentMonth, selectedDate]);
const handlePreviousMonth = () => {
setCurrentMonth(subMonths(currentMonth, 1));

View File

@@ -463,6 +463,13 @@ export default function Editor({
StarterKit.configure({
heading: disableHeadings ? false : undefined,
}),
Markdown,
Placeholder.configure({
placeholder: readOnly
? ""
: placeholder ??
t`Add description... (type '/' to open commands or '@' to mention)`,
}),
Link.configure({
openOnClick: true,
HTMLAttributes: {
@@ -472,14 +479,6 @@ export default function Editor({
},
validate: (href) => /^https?:\/\//.test(href),
autolink: true,
linkOnPaste: true,
}),
Markdown,
Placeholder.configure({
placeholder: readOnly
? ""
: placeholder ??
t`Add description... (type '/' to open commands or '@' to mention)`,
}),
SlashCommands.configure({
commandItems: getCommandItems(disableHeadings),

View File

@@ -75,7 +75,7 @@ export function LabelForm({
reset(newFormState);
}
} catch (e) {
console.error(e);
console.log(e);
}
},
});
@@ -231,7 +231,9 @@ export function LabelForm({
<Button
type="submit"
isLoading={updateLabel.isPending || createLabel.isPending}
disabled={!watch("name")}
disabled={
!watch("name")
}
>
{isEdit ? t`Update label` : t`Create label`}
</Button>

View File

@@ -33,7 +33,7 @@ const schema = z.object({
.min(3, {
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-]+$/, {
message: t`URL can only contain letters, numbers, and hyphens`,
})
@@ -111,7 +111,6 @@ export function NewWorkspaceForm() {
slug: values.slug,
plan: values.plan,
role: "admin",
weekStartDay: 1,
});
// If in cloud and Pro toggle is enabled, create checkout session for pro

View File

@@ -8,7 +8,6 @@ export const PageHead = ({ title }: { title: string }) => {
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1"
/>
<link rel="manifest" href="/manifest.json" />
</Head>
);
};

View File

@@ -12,7 +12,6 @@ import { useEffect, useState } from "react";
import {
HiChevronDown,
HiOutlineBanknotes,
HiOutlineBolt,
HiOutlineCodeBracketSquare,
HiOutlineRectangleGroup,
HiOutlineShieldCheck,
@@ -65,12 +64,6 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
label: t`API`,
condition: true,
},
{
key: "webhooks",
icon: <HiOutlineBolt />,
label: t`Webhooks`,
condition: isAdmin,
},
{
key: "integrations",
icon: <HiOutlineCodeBracketSquare />,

View File

@@ -19,7 +19,6 @@ interface UsePermissionsResult {
canCreateBoard: boolean;
canEditBoard: boolean;
canDeleteBoard: boolean;
canArchiveBoard: boolean;
canViewComment: boolean;
canCreateComment: boolean;
canEditComment: boolean;
@@ -34,7 +33,7 @@ interface UsePermissionsResult {
export function usePermissions(): UsePermissionsResult {
// Check if WorkspaceProvider is available (for public board views, it may not be)
const workspaceContext = useContext(WorkspaceContext);
// If WorkspaceProvider is not available, return safe defaults
if (!workspaceContext) {
const emptyPermissions: UsePermissionsResult = {
@@ -52,7 +51,6 @@ export function usePermissions(): UsePermissionsResult {
canCreateBoard: false,
canEditBoard: false,
canDeleteBoard: false,
canArchiveBoard: false,
canViewComment: false,
canCreateComment: false,
canEditComment: false,
@@ -97,7 +95,6 @@ export function usePermissions(): UsePermissionsResult {
canCreateBoard: hasPermission("board:create"),
canEditBoard: hasPermission("board:edit"),
canDeleteBoard: hasPermission("board:delete"),
canArchiveBoard: hasPermission("board:edit"),
canViewComment: hasPermission("comment:view"),
canCreateComment: hasPermission("comment:create"),
canEditComment: hasPermission("comment:edit"),

View File

@@ -1,44 +0,0 @@
import type { RefObject } from "react";
import type { NextRouter } from "next/router";
import { useEffect, useRef } from "react";
const scrollPositions = new Map<string, number>();
export function useScrollRestore(
boardId: string | null | undefined,
scrollRef: RefObject<HTMLElement | null>,
router: NextRouter,
isReady: boolean,
) {
const restored = useRef(false);
useEffect(() => {
if (!boardId) return;
restored.current = false;
const saveScrollPosition = () => {
if (scrollRef.current) {
scrollPositions.set(boardId, scrollRef.current.scrollLeft);
}
};
router.events.on("routeChangeStart", saveScrollPosition);
return () => router.events.off("routeChangeStart", saveScrollPosition);
}, [boardId, router.events, scrollRef]);
useEffect(() => {
if (restored.current || !isReady || !boardId) return;
restored.current = true;
const saved = scrollPositions.get(boardId);
if (saved === undefined) return;
// StrictModeDroppable delays rendering by one requestAnimationFrame
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (scrollRef.current) scrollRef.current.scrollLeft = saved;
});
});
}, [isReady, scrollRef, boardId]);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -7,7 +7,7 @@ export const locales = [
"nl",
"ru",
"pl",
"ptbr"
"pt-BR"
] as const;
export type Locale = (typeof locales)[number];
@@ -23,5 +23,5 @@ export const localeNames: Record<Locale, string> = {
nl: "Nederlands",
ru: "Русский",
pl: "Polski",
ptbr: "Português",
"pt-BR": "Português",
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,55 +0,0 @@
import Link from "next/link";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground";
export default function NotFoundPage() {
return (
<>
<PageHead title={t`404 - Page Not Found | kan.bn`} />
<main className="h-screen bg-light-100 pt-20 dark:bg-dark-50 sm:pt-0">
<div className="justify-top flex h-full flex-col items-center px-4 sm:justify-center">
<div className="z-10 flex w-full flex-col items-center">
<Link href="/">
<h1 className="mb-6 text-lg font-bold tracking-tight text-light-1000 dark:text-dark-1000">
kan.bn
</h1>
</Link>
<p className="mb-4 text-8xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
404
</p>
<p className="mb-10 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
<Trans>Page not found</Trans>
</p>
<div className="w-full rounded-lg border border-light-500 bg-light-300 px-4 py-10 dark:border-dark-400 dark:bg-dark-200 sm:max-w-md lg:px-10">
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
<p className="mb-6 text-center text-light-900 dark:text-dark-900">
<Trans>
The page you're looking for doesn't exist or has been moved.
</Trans>
</p>
<div className="flex flex-col gap-3">
<Link
href="/"
className="flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-100 shadow-sm hover:bg-light-900 dark:bg-dark-1000 dark:text-dark-100 dark:hover:bg-dark-900"
>
<Trans>Go to homepage</Trans>
</Link>
<Link
href="/boards"
className="flex w-full justify-center rounded-md border border-light-500 bg-light-100 px-3 py-2 text-sm font-semibold text-light-1000 shadow-sm hover:bg-light-200 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000 dark:hover:bg-dark-300"
>
<Trans>Go to boards</Trans>
</Link>
</div>
</div>
</div>
</div>
<PatternedBackground />
</div>
</main>
</>
);
}

View File

@@ -2,74 +2,48 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import { env } from "~/env";
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" });
if (req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" });
}
const { url, filename } = req.query;
if (!url || typeof url !== "string") {
return res.status(400).json({
message: "url parameter is required",
});
}
try {
const downloadFilename = typeof filename === "string"
? encodeURIComponent(filename)
: "attachment";
const upstream = await fetch(url);
if (!upstream.ok) {
return res.status(upstream.status).json({
message: "Failed to fetch attachment",
});
}
const { url, filename } = req.query;
const contentType =
upstream.headers.get("Content-Type") ?? "application/octet-stream";
if (!url || typeof url !== "string") {
return res.status(400).json({ message: "url parameter is required" });
}
res.setHeader("Content-Type", contentType);
res.setHeader(
"Content-Disposition",
`attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`,
);
const s3Endpoint = env.S3_ENDPOINT;
if (s3Endpoint) {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return res.status(400).json({ message: "Invalid URL" });
}
const hostname = parsed.hostname.toLowerCase();
let allowedHost: string;
try {
allowedHost = new URL(s3Endpoint).hostname.toLowerCase();
} catch {
return res.status(500).json({ message: "Storage endpoint misconfigured" });
}
if (hostname !== allowedHost && !hostname.endsWith(`.${allowedHost}`)) {
return res.status(403).json({ message: "URL not allowed" });
}
}
try {
const downloadFilename =
typeof filename === "string"
? encodeURIComponent(filename)
: "attachment";
const upstream = await fetch(url);
if (!upstream.ok) {
return res
.status(upstream.status)
.json({ message: "Failed to fetch attachment" });
}
const contentType =
upstream.headers.get("Content-Type") ?? "application/octet-stream";
res.setHeader("Content-Type", contentType);
res.setHeader(
"Content-Disposition",
`attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`,
);
const buffer = await upstream.arrayBuffer();
return res.send(Buffer.from(buffer));
} catch (error) {
console.error("Error downloading attachment:", error);
return res
.status(500)
.json({ message: "Failed to download attachment" });
}
const buffer = await upstream.arrayBuffer();
return res.send(Buffer.from(buffer));
} catch (error) {
console.error("Error downloading attachment:", error);
return res.status(500).json({ message: "Failed to download attachment" });
}
},
);

View File

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

View File

@@ -3,11 +3,8 @@ import type { Readable } from "node:stream";
import { createNextApiContext } from "@kan/api/trpc";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createLogger } from "@kan/logger";
import { createStripeClient } from "@kan/stripe";
const log = createLogger("stripe-webhook");
async function buffer(readable: Readable) {
const chunks = [];
for await (const chunk of readable) {
@@ -44,8 +41,6 @@ export default async function handler(
const { db } = await createNextApiContext(req);
log.info({ eventType: event.type, eventId: event.id }, "Stripe webhook received");
switch (event.type) {
case "checkout.session.completed": {
const checkoutSession = event.data.object;
@@ -61,12 +56,12 @@ export default async function handler(
break;
}
default:
log.warn({ eventType: event.type }, "Unhandled Stripe event type");
console.log(`Unhandled event type: ${event.type}`);
}
return res.status(200).json({ received: true });
} catch (err) {
log.error({ err }, "Stripe webhook handler failed");
console.error("Webhook error:", err);
return res.status(400).json({ message: "Webhook handler failed" });
}
}

View File

@@ -8,7 +8,7 @@ import { env } from "~/env";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import { createS3Client } from "@kan/shared/utils";
const MAX_SIZE_BYTES = parseInt(process.env.S3_AVATAR_UPLOAD_LIMIT || '2097152', 10); // Default 2MB
const MAX_SIZE_BYTES = 2 * 1024 * 1024; // 2MB
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
export const config = {

View File

@@ -1,25 +1,24 @@
import { useEffect } from "react";
export default function TrelloAuthorize() {
useEffect(() => {
const hash = window.location.hash;
const token = hash.split("=")[1];
if (token) {
fetch("/api/trello/authenticate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ token }),
}).then(() => {
window.close();
});
}
}, []);
useEffect(() => {
const hash = window.location.hash;
const token = hash.split("=")[1];
if (token) {
console.log("Posting token to /api/trello/authenticate", token);
fetch("/api/trello/authenticate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ token }),
}).then(() => {
window.close();
});
}
}, []);
return (
<div className="flex h-[200px] items-center justify-center">
<p className="text-center">Connecting to Trello...</p>
</div>
);
}
return <div className="flex h-[200px] items-center justify-center">
<p className="text-center">Connecting to Trello...</p>
</div>;
}

View File

@@ -1,16 +0,0 @@
import type { NextPageWithLayout } from "~/pages/_app";
import { getDashboardLayout } from "~/components/Dashboard";
import { SettingsLayout } from "~/components/SettingsLayout";
import WebhookSettings from "~/views/settings/WebhookSettings";
const WebhookSettingsPage: NextPageWithLayout = () => {
return (
<SettingsLayout currentTab="webhooks">
<WebhookSettings />
</SettingsLayout>
);
};
WebhookSettingsPage.getLayout = (page) => getDashboardLayout(page);
export default WebhookSettingsPage;

View File

@@ -19,7 +19,6 @@ interface Workspace {
slug: string | undefined;
plan: "free" | "pro" | "enterprise" | undefined;
role: "admin" | "member" | "guest";
weekStartDay: 0 | 1 | 6;
}
const initialWorkspace: Workspace = {
@@ -29,14 +28,13 @@ const initialWorkspace: Workspace = {
slug: "",
plan: "free",
role: "member",
weekStartDay: 1,
};
const initialAvailableWorkspaces: Workspace[] = [];
export const WorkspaceContext = createContext<
WorkspaceContextProps | undefined
>(undefined);
export const WorkspaceContext = createContext<WorkspaceContextProps | undefined>(
undefined,
);
export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
children,
@@ -81,7 +79,6 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
slug: workspace.slug,
description: workspace.description,
plan: workspace.plan,
weekStartDay: workspace.weekStartDay,
hasLoaded: true,
})) as Workspace[];
@@ -103,7 +100,6 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
plan: selectedWorkspace.workspace.plan,
description: selectedWorkspace.workspace.description,
role: selectedWorkspace.role,
weekStartDay: selectedWorkspace.workspace.weekStartDay as 0 | 1 | 6,
});
if (workspacePublicId) {
@@ -123,7 +119,6 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
plan: primaryWorkspace.plan,
description: primaryWorkspace.description,
role: primaryWorkspaceRole,
weekStartDay: primaryWorkspace.weekStartDay as 0 | 1 | 6,
});
}
}, [data, isLoading, workspacePublicId, router]);

View File

@@ -58,7 +58,7 @@ export default function LoginPage() {
</div>
</div>
)}
{(!isSignUpDisabled || redirect?.startsWith("/invite/")) && (
{!isSignUpDisabled && (
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
<Trans>
Don't have an account?{" "}

View File

@@ -28,9 +28,7 @@ export default function SignUpPage() {
setMagicLinkRecipient(recipient);
};
const isInviteFlow = redirect?.startsWith("/invite/");
if (isSignUpDisabled && !isInviteFlow) {
if (isSignUpDisabled) {
return (
<>
<PageHead title={t`Sign up | kan.bn`} />

View File

@@ -7,7 +7,7 @@ import {
HiOutlineStar,
HiStar,
} from "react-icons/hi2";
import { IoArchiveOutline } from "react-icons/io5";
import Dropdown from "~/components/Dropdown";
import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal";
@@ -17,7 +17,6 @@ import { api } from "~/utils/api";
export default function BoardDropdown({
isTemplate,
isLoading,
isArchived,
boardPublicId,
isFavorite,
boardName,
@@ -25,29 +24,28 @@ export default function BoardDropdown({
isTemplate: boolean;
isLoading: boolean;
boardPublicId: string;
isArchived?: boolean;
isFavorite?: boolean;
boardName?: string;
}) {
const { openModal } = useModal();
const { canEditBoard, canDeleteBoard, canCreateBoard } = usePermissions();
const { showPopup } = usePopup();
const { canEditBoard, canDeleteBoard, canCreateBoard, canArchiveBoard } =
usePermissions();
const utils = api.useUtils();
const handleToggleFavorite = () => {
updateBoard.mutate({
boardPublicId,
favorite: !isFavorite,
});
};
const updateBoard = api.board.update.useMutation({
onSuccess: (_data, variables) => {
onSuccess: (data, variables) => {
void utils.board.all.invalidate();
void utils.board.byId.invalidate();
if (variables.isArchived !== undefined) {
showPopup({
header: variables.isArchived ? t`Board archived` : t`Board unarchived`,
message: variables.isArchived
? t`The board has been archived.`
: t`The board has been unarchived.`,
icon: "success",
});
} else if (variables.favorite !== undefined) {
// Show popup notification
if (variables.favorite !== undefined) {
showPopup({
header: variables.favorite
? t`Added to favorites`
@@ -67,54 +65,27 @@ export default function BoardDropdown({
});
},
});
const handleToggleFavorite = () => {
updateBoard.mutate({
boardPublicId,
favorite: !isFavorite,
});
};
const handleArchiveOrUnarchive = () => {
updateBoard.mutate({
boardPublicId,
isArchived: !isArchived,
});
};
const isArchiveActionPending = updateBoard.isPending;
const items = [
...(isTemplate && canCreateBoard
? [
{
label: t`Make template`,
action: () => openModal("CREATE_TEMPLATE"),
icon: (
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
),
},
]
{
label: t`Make template`,
action: () => openModal("CREATE_TEMPLATE"),
icon: (
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
),
},
]
: []),
...(!isTemplate && canEditBoard
? [
{
label: t`Edit board URL`,
action: () => openModal("UPDATE_BOARD_SLUG"),
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
},
]
: []),
...(!isTemplate && canArchiveBoard
? [
{
label: isArchived ? t`Unarchive board` : t`Archive board`,
action: handleArchiveOrUnarchive,
icon: (
<IoArchiveOutline className="h-[16px] w-[16px] text-dark-900" />
),
},
]
{
label: t`Edit board URL`,
action: () => openModal("UPDATE_BOARD_SLUG"),
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
},
]
: []),
{
label: isFavorite
@@ -129,26 +100,22 @@ export default function BoardDropdown({
},
...(canDeleteBoard
? [
{
label: isTemplate ? t`Delete template` : t`Delete board`,
action: () => openModal("DELETE_BOARD"),
icon: (
<HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />
),
},
]
{
label: isTemplate ? t`Delete template` : t`Delete board`,
action: () => openModal("DELETE_BOARD"),
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
},
]
: []),
];
if (items.length === 0) {
return null;
}
return (
<Dropdown
disabled={isLoading || isArchiveActionPending}
items={items}
>
<Dropdown disabled={isLoading} items={items}>
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
</Dropdown>
);

View File

@@ -24,7 +24,6 @@ import Toggle from "~/components/Toggle";
import { useModalFormState } from "~/hooks/useModalFormState";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
@@ -54,7 +53,6 @@ export function NewCardForm({
queryParams,
}: NewCardFormProps) {
const { showPopup } = usePopup();
const { workspace } = useWorkspace();
const { closeModal, openModal, modalStates, clearModalState } = useModal();
const utils = api.useUtils();
@@ -493,7 +491,6 @@ export function NewCardForm({
setValue("dueDate", date ?? null);
setIsDateSelectorOpen(false);
}}
weekStartsOn={workspace.weekStartDay}
/>
</div>
</>

View File

@@ -26,7 +26,6 @@ import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppab
import { Tooltip } from "~/components/Tooltip";
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
import { useDragToScroll } from "~/hooks/useDragToScroll";
import { useScrollRestore } from "~/hooks/useScrollRestore";
import { usePermissions } from "~/hooks/usePermissions";
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
@@ -125,21 +124,11 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
data: boardData,
isSuccess,
isLoading: isQueryLoading,
error,
} = api.board.byId.useQuery(queryParams, {
enabled: !!boardId,
placeholderData: keepPreviousData,
});
// Redirect to 404 if board doesn't exist
useEffect(() => {
if (router.isReady && boardId && !isQueryLoading) {
if (error?.data?.code === "NOT_FOUND" || (!boardData && !isQueryLoading)) {
router.replace("/404");
}
}
}, [router, boardId, isQueryLoading, error, boardData]);
const refetchBoard = async () => {
if (boardId) await utils.board.byId.refetch({ boardPublicId: boardId });
};
@@ -152,8 +141,6 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
const isLoading = isInitialLoading || isQueryLoading;
useScrollRestore(boardId, scrollRef, router, !isLoading && (boardData?.lists.length ?? 0) > 0);
const updateListMutation = api.list.update.useMutation({
onMutate: async (args) => {
await utils.board.byId.cancel();
@@ -507,7 +494,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
isTemplate={!!isTemplate}
isLoading={!boardData}
boardPublicId={boardId ?? ""}
isArchived={boardData?.isArchived ?? false}
workspacePublicId={workspace.publicId}
isFavorite={boardData?.favorite}
boardName={boardData?.name}
/>
@@ -611,12 +598,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
? `/templates/${boardId}/cards/${card.publicId}`
: `/cards/${card.publicId}`
}
className={`mb-2 flex !cursor-pointer flex-col ${card.publicId.startsWith(
"PLACEHOLDER",
)
? "pointer-events-none"
: ""
}`}
className={`mb-2 flex !cursor-pointer flex-col ${
card.publicId.startsWith(
"PLACEHOLDER",
)
? "pointer-events-none"
: ""
}`}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}

View File

@@ -10,7 +10,7 @@ import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
export function BoardsList({ isTemplate, archived = false }: { isTemplate?: boolean; archived?: boolean }) {
export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
const { workspace } = useWorkspace();
const { openModal } = useModal();
const { canCreateBoard } = usePermissions();
@@ -26,7 +26,6 @@ export function BoardsList({ isTemplate, archived = false }: { isTemplate?: bool
{
workspacePublicId: workspace.publicId,
type: isTemplate ? "template" : "regular",
archived: archived,
},
{ enabled: workspace.publicId ? true : false },
);
@@ -60,10 +59,10 @@ export function BoardsList({ isTemplate, archived = false }: { isTemplate?: bool
<div className="flex flex-col items-center">
<HiOutlineRectangleStack className="h-10 w-10 text-light-800 dark:text-dark-800" />
<p className="mb-2 mt-4 text-[14px] font-bold text-light-1000 dark:text-dark-950">
{archived ? t`No archived boards` : t`No ${isTemplate ? "templates" : "boards"}`}
{t`No ${isTemplate ? "templates" : "boards"}`}
</p>
<p className="text-[14px] text-light-900 dark:text-dark-900">
{archived ? t`Boards you archive will appear here.` : t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
{t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
</p>
</div>
<Tooltip
@@ -110,18 +109,18 @@ export function BoardsList({ isTemplate, archived = false }: { isTemplate?: bool
>
<div className="group relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
<PatternedBackground />
<button
onClick={(e) => handleToggleFavorite(e, board.publicId, board.favorite)}
className={`absolute right-3 top-3 z-10 rounded p-1 transition-all hover:bg-light-300 dark:hover:bg-dark-200 ${board.favorite ? "" : "md:opacity-0 md:group-hover:opacity-100"
}`}
aria-label={board.favorite ? "Remove from favorites" : "Add to favorites"}
>
{board.favorite ? (
<HiStar className="h-5 w-5 text-neutral-700 dark:text-dark-1000" />
) : (
<HiOutlineStar className="h-5 w-5 text-neutral-700 dark:text-dark-800" />
)}
</button>
<button
onClick={(e) => handleToggleFavorite(e, board.publicId, board.favorite)}
className={`absolute right-3 top-3 z-10 rounded p-1 transition-all hover:bg-light-300 dark:hover:bg-dark-200 ${board.favorite ? "" : "md:opacity-0 md:group-hover:opacity-100"
}`}
aria-label={board.favorite ? "Remove from favorites" : "Add to favorites"}
>
{board.favorite ? (
<HiStar className="h-5 w-5 text-neutral-700 dark:text-dark-1000" />
) : (
<HiOutlineStar className="h-5 w-5 text-neutral-700 dark:text-dark-800" />
)}
</button>
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
{board.name}
</p>

View File

@@ -4,7 +4,7 @@ import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { Fragment, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { FaGithub, FaTrello } from "react-icons/fa";
import { FaTrello } from "react-icons/fa";
import {
HiChevronUpDown,
HiMiniArrowTopRightOnSquare,
@@ -27,23 +27,12 @@ const integrationProviders: Record<
name: "Trello",
icon: <FaTrello />,
},
github: {
name: "GitHub",
icon: <FaGithub />,
},
};
const SelectSource = ({
handleNextStep,
}: {
handleNextStep: (provider: string) => void;
}) => {
const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
const { data: integrations, refetch: refetchIntegrations } =
api.integration.providers.useQuery();
const { data: githubStatus, refetch: refetchGithubStatus } =
api.integration.getGitHubStatus.useQuery();
const { control, handleSubmit, watch } = useForm({
const { control, handleSubmit } = useForm({
defaultValues: {
source: integrations?.[0]?.provider ?? "trello",
},
@@ -58,36 +47,23 @@ const SelectSource = ({
},
);
const availableIntegrations = [
...(integrations ?? []),
...(githubStatus?.connected ? [{ provider: "github" }] : []),
];
const hasIntegrations = availableIntegrations.length > 0;
const hasIntegrations = integrations && integrations.length > 0;
useEffect(() => {
const handleFocus = () => {
void refetchIntegrations();
void refetchGithubStatus();
refetchIntegrations();
};
window.addEventListener("focus", handleFocus);
return () => {
window.removeEventListener("focus", handleFocus);
};
}, [refetchIntegrations, refetchGithubStatus]);
}, [refetchIntegrations]);
const onSubmit = () => {
const selected = watch("source");
if (
selected === "trello" &&
!integrations?.some((i) => i.provider === "trello")
) {
if (trelloUrl)
window.open(trelloUrl.url, "trello_auth", "height=800,width=600");
} else if (selected === "github" && !githubStatus?.connected) {
window.open("/settings/integrations", "_blank");
if (!hasIntegrations && trelloUrl) {
window.open(trelloUrl.url, "trello_auth", "height=800,width=600");
} else {
handleNextStep(selected);
handleNextStep();
}
};
@@ -126,7 +102,7 @@ const SelectSource = ({
>
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-light-50 py-1 text-base text-neutral-900 shadow-lg ring-1 ring-light-600 ring-opacity-5 focus:outline-none dark:bg-dark-300 dark:text-dark-1000 sm:text-sm">
{hasIntegrations ? (
availableIntegrations.map((integration, index) => (
integrations.map((integration, index) => (
<Listbox.Option
key={`source_${index}`}
className="relative cursor-default select-none px-1"
@@ -147,32 +123,18 @@ const SelectSource = ({
</Listbox.Option>
))
) : (
<>
<Listbox.Option
key="trello_placeholder"
className="font-sm relative cursor-default select-none px-1"
value="trello"
>
<div className="flex items-center rounded-[5px] p-1 text-sm hover:bg-light-200 dark:hover:bg-dark-400">
{integrationProviders.trello?.icon}
<span className="ml-2 block truncate text-sm">
{integrationProviders.trello?.name}
</span>
</div>
</Listbox.Option>
<Listbox.Option
key="github_placeholder"
className="font-sm relative cursor-default select-none px-1"
value="github"
>
<div className="flex items-center rounded-[5px] p-1 text-sm hover:bg-light-200 dark:hover:bg-dark-400">
{integrationProviders.github?.icon}
<span className="ml-2 block truncate text-sm">
{integrationProviders.github?.name}
</span>
</div>
</Listbox.Option>
</>
<Listbox.Option
key="trello_placeholder"
className="font-sm relative cursor-default select-none px-1"
value="trello"
>
<div className="flex items-center rounded-[5px] p-1 text-sm hover:bg-light-200 dark:hover:bg-dark-400">
{integrationProviders.trello?.icon}
<span className="ml-2 block truncate text-sm">
{integrationProviders.trello?.name}
</span>
</div>
</Listbox.Option>
)}
</Listbox.Options>
</Transition>
@@ -192,159 +154,7 @@ const SelectSource = ({
!hasIntegrations ? <HiMiniArrowTopRightOnSquare /> : undefined
}
>
{hasIntegrations ? t`Select source` : t`Connect`}
</Button>
</div>
</div>
</form>
);
};
const ImportGithub: React.FC = () => {
const utils = api.useUtils();
const { closeModal } = useModal();
const { workspace } = useWorkspace();
const { showPopup } = usePopup();
const [isSelectAllEnabled, setIsSelectAllEnabled] = useState(false);
const refetchBoards = () => utils.board.all.refetch();
const { data: projects, isLoading: projectsLoading } =
api.import.github.getProjects.useQuery();
const {
register: registerProjects,
handleSubmit: handleSubmitProjects,
setValue,
watch,
} = useForm({
defaultValues: Object.fromEntries(
projects?.map((project) => [project.id, true]) ?? [],
),
});
const importProjects = api.import.github.importProjects.useMutation({
onSuccess: async () => {
showPopup({
header: t`Import complete`,
message: t`Your projects have been imported.`,
icon: "success",
});
try {
await refetchBoards();
closeModal();
} catch (e) {
console.error(e);
}
},
onError: () => {
showPopup({
header: t`Import failed`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
});
const projectWatchers = projects?.map((project) => ({
id: project.id,
value: watch(project.id),
}));
const projectCount =
projectWatchers?.filter((w) => w.value === true).length ?? 0;
const onSubmitProjects = (values: Record<string, boolean>) => {
const projectIds = Object.keys(values).filter(
(key) => values[key] === true,
);
importProjects.mutate({
projectIds,
workspacePublicId: workspace.publicId,
});
};
const renderContent = () => {
if (projectsLoading) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-1">
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
</div>
);
}
if (!projects?.length) {
return (
<div className="flex h-full w-full items-center justify-center">
<p className="text-sm text-neutral-500 dark:text-dark-900">
{t`No projects found`}
</p>
</div>
);
}
return projects.map((project) => (
<div key={project.id}>
<label
className="flex cursor-pointer items-center rounded-[5px] p-2 hover:bg-light-100 dark:hover:bg-dark-300"
htmlFor={project.id}
>
<input
id={project.id}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent ring-0 focus:outline-none focus:ring-0 focus:ring-offset-0"
{...registerProjects(project.id)}
/>
<span className="ml-3 text-sm text-neutral-900 dark:text-dark-1000">
{project.name}
</span>
</label>
</div>
));
};
return (
<form onSubmit={handleSubmitProjects(onSubmitProjects)}>
<div className="h-[105px] overflow-auto px-5">{renderContent()}</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
label={t`Select all`}
isChecked={!!isSelectAllEnabled}
onChange={() => {
const newState = !isSelectAllEnabled;
setIsSelectAllEnabled(newState);
for (const project of projects ?? []) {
setValue(project.id, newState);
}
}}
/>
<div className="space-x-2">
<Button
type="submit"
isLoading={importProjects.isPending}
disabled={
importProjects.isPending ||
projectsLoading ||
!projects?.length ||
!projects.some(
(project) =>
projectWatchers?.find((w) => w.id === project.id)?.value ===
true,
)
}
>
<Trans>
<Plural
value={projectCount}
one={`Import project (1)`}
other={`Import projects (${projectCount})`}
/>
</Trans>
{hasIntegrations ? t`Select source` : t`Connect Trello`}
</Button>
</div>
</div>
@@ -386,7 +196,7 @@ const ImportTrello: React.FC = () => {
await refetchBoards();
closeModal();
} catch (e) {
console.error(e);
console.log(e);
}
},
onError: () => {
@@ -403,7 +213,7 @@ const ImportTrello: React.FC = () => {
value: watch(board.id),
}));
const boardCount = boardWatchers?.filter((w) => w.value === true).length ?? 0;
const boardCount = boardWatchers?.filter((w) => w.value === true).length || 0;
const onSubmitBoards = (values: Record<string, boolean>) => {
const boardIds = Object.keys(values).filter((key) => values[key] === true);
@@ -457,7 +267,7 @@ const ImportTrello: React.FC = () => {
return (
<form onSubmit={handleSubmitBoards(onSubmitBoards)}>
<div className="h-[105px] overflow-auto px-5">{renderContent()}</div>
<div className="h-[105px] overflow-scroll px-5">{renderContent()}</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
@@ -467,7 +277,7 @@ const ImportTrello: React.FC = () => {
const newState = !isSelectAllEnabled;
setIsSelectAllEnabled(newState);
for (const board of boards ?? []) {
for (const board of boards || []) {
setValue(board.id, newState);
}
}}
@@ -503,7 +313,6 @@ const ImportTrello: React.FC = () => {
export function ImportBoardsForm() {
const { closeModal } = useModal();
const [step, setStep] = useState(1);
const [provider, setProvider] = useState<string | null>(null);
return (
<div>
@@ -530,16 +339,8 @@ export function ImportBoardsForm() {
</button>
</div>
{step === 1 && (
<SelectSource
handleNextStep={(p) => {
setProvider(p);
setStep(step + 1);
}}
/>
)}
{step === 2 && provider === "trello" && <ImportTrello />}
{step === 2 && provider === "github" && <ImportGithub />}
{step === 1 && <SelectSource handleNextStep={() => setStep(step + 1)} />}
{step === 2 && <ImportTrello />}
</div>
);
}

View File

@@ -1,12 +1,5 @@
import {
Listbox,
ListboxButton,
ListboxOption,
ListboxOptions,
} from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { HiArrowDownTray, HiChevronDown, HiOutlinePlusSmall } from "react-icons/hi2";
import { useState } from "react";
import { HiArrowDownTray, HiOutlinePlusSmall } from "react-icons/hi2";
import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal";
@@ -22,15 +15,9 @@ import { BoardsList } from "./components/BoardsList";
import { ImportBoardsForm } from "./components/ImportBoardsForm";
import { NewBoardForm } from "./components/NewBoardForm";
const boardsTabs = [
{ key: "boards" as const, label: t`Active` },
{ key: "archived" as const, label: t`Archived` },
];
export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
const { openModal, modalContentType, isOpen } = useModal();
const { workspace } = useWorkspace();
const [activeTab, setActiveTab] = useState<"boards" | "archived">("boards");
const { canCreateBoard } = usePermissions();
const { tooltipContent: createModalShortcutTooltipContent } =
@@ -47,7 +34,7 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
<PageHead
title={t`${isTemplate ? "Templates" : "Boards"} | ${workspace.name ?? t`Workspace`}`}
/>
<div className="m-auto h-full max-w-[1100px] p-8 px-5 md:px-28 md:py-12">
<div className="m-auto h-full max-w-[1100px] p-6 px-5 md:px-28 md:py-12">
<div className="relative z-10 mb-8 flex w-full items-center justify-between">
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{t`${isTemplate ? "Templates" : "Boards"}`}
@@ -128,79 +115,9 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
</Modal>
</>
{!isTemplate ? (
<div className="flex h-full w-full flex-col">
<div className="focus:outline-none">
<div className="sm:hidden">
<Listbox
value={activeTab}
onChange={(tab) => setActiveTab(tab)}
>
<div className="relative mb-4">
<ListboxButton className="w-full appearance-none rounded-md border-0 bg-light-50 py-3 pl-3 pr-10 text-left text-sm font-semibold text-light-1000 shadow-sm ring-1 ring-inset ring-light-300 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500">
{boardsTabs.find((tab) => tab.key === activeTab)?.label ??
"Select a tab"}
<HiChevronDown
aria-hidden="true"
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-light-900 dark:text-dark-900"
/>
</ListboxButton>
<ListboxOptions className="absolute z-10 mt-1 w-full rounded-md bg-light-50 py-1 text-sm shadow-lg ring-1 ring-inset ring-light-300 dark:bg-dark-50 dark:ring-dark-300">
{boardsTabs.map((tab) => (
<ListboxOption
key={tab.key}
value={tab.key}
className={({ selected }) =>
`relative cursor-pointer select-none py-2 pl-3 pr-9 ${selected
? "font-bold text-light-1000 dark:text-dark-1000"
: "font-normal text-light-1000 dark:text-dark-1000"
}`
}
>
{tab.label}
</ListboxOption>
))}
</ListboxOptions>
</div>
</Listbox>
</div>
<div className="hidden sm:block">
<div>
<nav
aria-label="Tabs"
className="-mb-px flex space-x-8 focus:outline-none"
>
{boardsTabs.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key)}
className={`whitespace-nowrap px-1 py-0 mt-2 mb-8 text-sm font-semibold transition-colors focus:outline-none ${activeTab === tab.key
? "border-light-1000 text-light-1000 dark:border-dark-1000 dark:text-dark-1000"
: "border-transparent text-light-900 hover:border-light-950 hover:text-light-950 dark:text-dark-900 dark:hover:border-white/20 dark:hover:text-dark-950"
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
</div>
</div>
<div className="flex h-full flex-row focus:outline-none">
{activeTab === "boards" && (
<BoardsList isTemplate={false} archived={false} />
)}
{activeTab === "archived" && (
<BoardsList isTemplate={false} archived={true} />
)}
</div>
</div>
) : (
<div className="flex h-full flex-row">
<BoardsList isTemplate={!!isTemplate} />
</div>
)}
<div className="flex h-full flex-row">
<BoardsList isTemplate={!!isTemplate} />
</div>
</div>
</>
);

View File

@@ -5,7 +5,6 @@ import { HiMiniPlus } from "react-icons/hi2";
import DateSelector from "~/components/DateSelector";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
@@ -23,7 +22,6 @@ export function DueDateSelector({
disabled = false,
}: DueDateSelectorProps) {
const { showPopup } = usePopup();
const { workspace } = useWorkspace();
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const [pendingDate, setPendingDate] = useState<Date | null | undefined>(
@@ -137,7 +135,6 @@ export function DueDateSelector({
<DateSelector
selectedDate={pendingDate ?? undefined}
onDateSelect={handleDateSelect}
weekStartsOn={workspace.weekStartDay}
/>
</div>
</>

View File

@@ -3,8 +3,8 @@ import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
import { IoChevronForwardSharp } from "react-icons/io5";
import { HiXMark } from "react-icons/hi2";
import { authClient } from "@kan/auth/client";
@@ -185,20 +185,11 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
? router.query.cardId[0]
: router.query.cardId;
const { data: card, isLoading, error } = api.card.byId.useQuery(
const { data: card, isLoading } = api.card.byId.useQuery(
{ cardPublicId: cardId ?? "" },
{ enabled: !!cardId && cardId.length >= 12 },
);
// Redirect to 404 if card doesn't exist
useEffect(() => {
if (router.isReady && cardId && !isLoading) {
if (error?.data?.code === "NOT_FOUND" || (!card && !isLoading)) {
router.replace("/404");
}
}
}, [router, cardId, isLoading, error, card]);
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
const canEdit = canEditCard || isCreator;
@@ -311,6 +302,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
if (!cardId) return <></>;
return (
<>
<PageHead
@@ -353,7 +345,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
className="flex h-7 w-7 items-center justify-center rounded-[5px] text-light-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200"
aria-label={t`Close`}
>
<HiXMark className="h-4 w-4" />
<HiXMark className="h-5 w-5" />
</Link>
</div>
</>

View File

@@ -8,10 +8,10 @@ type FrequencyValue = "monthly" | "annually";
interface PlanFeature {
key: string;
label: string;
free: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
teams: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
pro: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
enterprise: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
free: string | boolean | { text: string; highlight?: string };
teams: string | boolean | { text: string; highlight?: string };
pro: string | boolean | { text: string; highlight?: string };
enterprise: string | boolean | { text: string; highlight?: string };
}
interface FeatureSection {
@@ -71,34 +71,34 @@ const FeatureComparisonTable = ({
{
key: "boards",
label: t`Boards`,
free: { text: t`Unlimited boards`, highlightFirstWord: true },
teams: { text: t`Unlimited boards`, highlightFirstWord: true },
pro: { text: t`Unlimited boards`, highlightFirstWord: true },
enterprise: { text: t`Unlimited boards`, highlightFirstWord: true },
free: { text: t`Unlimited boards`, highlight: "Unlimited" },
teams: { text: t`Unlimited boards`, highlight: "Unlimited" },
pro: { text: t`Unlimited boards`, highlight: "Unlimited" },
enterprise: { text: t`Unlimited boards`, highlight: "Unlimited" },
},
{
key: "members",
label: t`Members`,
free: { text: t`1 user`, highlightFirstWord: true },
teams: { text: t`Per-seat pricing`, highlightFirstWord: true },
pro: { text: t`Unlimited members`, highlightFirstWord: true },
enterprise: { text: t`Unlimited members`, highlightFirstWord: true },
free: { text: t`1 user`, highlight: "1" },
teams: { text: t`Per-seat pricing`, highlight: "Per-seat" },
pro: { text: t`Unlimited members`, highlight: "Unlimited" },
enterprise: { text: t`Unlimited members`, highlight: "Unlimited" },
},
{
key: "file-uploads",
label: t`File uploads`,
free: { text: t`10mb file uploads`, highlightFirstWord: true },
teams: { text: t`Unlimited file uploads`, highlightFirstWord: true },
pro: { text: t`Unlimited file uploads`, highlightFirstWord: true },
enterprise: { text: t`Unlimited file uploads`, highlightFirstWord: true },
free: { text: t`10mb file uploads`, highlight: "10mb" },
teams: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
pro: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
enterprise: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
},
{
key: "workspace-username",
label: t`Workspace username`,
free: { text: t`Default username`, highlightFirstWord: true },
teams: { text: t`Default username`, highlightFirstWord: true },
pro: { text: t`Custom username`, highlightFirstWord: true },
enterprise: { text: t`Custom username`, highlightFirstWord: true },
free: { text: t`Default username`, highlight: "Default" },
teams: { text: t`Default username`, highlight: "Default" },
pro: { text: t`Custom username`, highlight: "Custom" },
enterprise: { text: t`Custom username`, highlight: "Custom" },
},
];
@@ -236,9 +236,9 @@ const FeatureComparisonTable = ({
key: "rest-api",
label: t`API`,
free: { text: t`Limited API access` },
teams: { text: t`Full API access`, highlight: true },
pro: { text: t`Full API access`, highlight: true },
enterprise: { text: t`Full API access`, highlight: true },
teams: { text: t`Full API access` },
pro: { text: t`Full API access` },
enterprise: { text: t`Full API access` },
},
{
key: "self-hostable",
@@ -254,18 +254,18 @@ const FeatureComparisonTable = ({
{
key: "sso",
label: t`SSO`,
free: { text: t`Google SSO`, highlight: true },
teams: { text: t`Google SSO`, highlight: true },
pro: { text: t`Google SSO`, highlight: true },
enterprise: { text: t`Google SSO + SAML`, highlight: true },
free: { text: t`Google SSO` },
teams: { text: t`Google SSO` },
pro: { text: t`Google SSO` },
enterprise: { text: t`Google SSO + SAML` },
},
{
key: "admin-roles",
label: t`Admin roles`,
free: { text: t`Admin roles`, highlight: true },
teams: { text: t`Admin roles`, highlight: true },
pro: { text: t`Admin roles`, highlight: true },
enterprise: { text: t`Advanced admin roles`, highlight: true },
free: { text: t`Admin roles` },
teams: { text: t`Admin roles` },
pro: { text: t`Admin roles` },
enterprise: { text: t`Advanced admin roles` },
},
];
@@ -334,37 +334,59 @@ const FeatureComparisonTable = ({
{ id: "enterprise", name: t`Enterprise`, tierId: "tier-enterprise" },
];
const isHighlightValue = (
value: string | boolean | { text: string; highlight?: string },
): value is { text: string; highlight?: string } => {
return typeof value === "object" && value !== null && "text" in value;
};
const shouldHighlight = (text: string): boolean => {
const lowerText = text.toLowerCase();
return ["unlimited", "multiple", "per-seat", "custom", "mentions", "full", "sso", "admin"].some(
(keyword) => lowerText.includes(keyword),
);
};
const CellValue = ({
value,
label,
}: {
value: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
value: string | boolean | { text: string; highlight?: string };
label: string;
}) => {
if (typeof value === "object" && value !== null) {
const { text, highlight, highlightFirstWord } = value;
if (highlightFirstWord) {
const spaceIndex = text.indexOf(" ");
const firstWord = spaceIndex === -1 ? text : text.slice(0, spaceIndex);
const rest = spaceIndex === -1 ? "" : text.slice(spaceIndex);
return (
<div className="flex items-center gap-2.5">
<HiCheckCircle className="mt-0.5 h-4 w-4 shrink-0 text-light-1000 dark:text-dark-1000" />
<span className="text-sm font-medium">
<span className="text-light-1000 dark:text-dark-1000">{firstWord}</span>
<span className="text-light-950 dark:text-dark-800">{rest}</span>
</span>
</div>
// Handle object with text and highlight
if (isHighlightValue(value)) {
const { text, highlight } = value;
const isUnlimited = shouldHighlight(text);
let displayText;
if (highlight) {
const parts = text.split(new RegExp(`(${highlight})`, "gi"));
displayText = (
<>
{parts.map((part, index) =>
part.toLowerCase() === highlight.toLowerCase() ? (
<span key={index} className="text-light-1000 dark:text-dark-1000">
{part}
</span>
) : (
<span key={index} className="text-light-950 dark:text-dark-800">
{part}
</span>
),
)}
</>
);
} else {
displayText = text;
}
return (
<div className="flex items-center gap-2.5">
<HiCheckCircle
className={twMerge(
"mt-0.5 h-4 w-4 shrink-0",
highlight
isUnlimited
? "text-light-1000 dark:text-dark-1000"
: "text-light-400 dark:text-dark-600",
)}
@@ -372,18 +394,44 @@ const FeatureComparisonTable = ({
<span
className={twMerge(
"text-sm font-medium",
highlight
isUnlimited
? "text-light-1000 dark:text-dark-950"
: "text-light-950 dark:text-dark-800",
)}
>
{text}
{displayText}
</span>
</div>
);
}
if (typeof value === "string") {
const isUnlimited = shouldHighlight(value);
return (
<div className="flex items-center gap-2.5">
<HiCheckCircle
className={twMerge(
"mt-0.5 h-4 w-4 shrink-0",
isUnlimited
? "text-light-1000 dark:text-dark-1000"
: "text-light-400 dark:text-dark-600",
)}
/>
<span
className={twMerge(
"text-sm font-medium",
isUnlimited
? "text-light-1000 dark:text-dark-950"
: "text-light-950 dark:text-dark-800",
)}
>
{value}
</span>
</div>
);
}
if (value) {
// Boolean true - show checkmark icon
return (
<div className="flex items-center gap-2.5">
<HiCheckCircle className="mt-0.5 h-4 w-4 shrink-0 text-light-1000 dark:text-dark-1000" />
@@ -393,7 +441,7 @@ const FeatureComparisonTable = ({
</div>
);
}
// Boolean false - show cross icon
return (
<div className="flex items-center gap-2.5">
<HiXCircle className="mt-0.5 h-4 w-4 shrink-0 text-light-400 dark:text-dark-600" />
@@ -407,7 +455,7 @@ const FeatureComparisonTable = ({
const getFeatureValue = (
feature: PlanFeature,
planId: string,
): string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean } => {
): string | boolean | { text: string; highlight?: string } => {
switch (planId) {
case "free":
return feature.free;

View File

@@ -47,7 +47,7 @@ export function CardModal({
}
};
const { data, isLoading, error } = api.card.byId.useQuery(
const { data, isLoading } = api.card.byId.useQuery(
{
cardPublicId: cardPublicId ?? "",
},
@@ -56,17 +56,6 @@ export function CardModal({
},
);
// Redirect to 404 if card doesn't exist
useEffect(() => {
if (isOpen && cardPublicId && !isLoading) {
if (error?.data?.code === "NOT_FOUND" || (!data && !isLoading && error)) {
// Close modal first, then redirect
closeModal();
router.replace("/404");
}
}
}, [isOpen, cardPublicId, isLoading, error, data, closeModal, router]);
const labels = data?.labels ?? [];
const handleScroll = () => {

View File

@@ -1,6 +1,5 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect } from "react";
import { t } from "@lingui/core/macro";
import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground";
@@ -13,22 +12,13 @@ export default function PublicBoardsView() {
? router.query.workspaceSlug[0]
: router.query.workspaceSlug;
const { data, isLoading, error } = api.workspace.bySlug.useQuery(
const { data, isLoading } = api.workspace.bySlug.useQuery(
{
workspaceSlug: workspaceSlug ?? "",
},
{ enabled: !!workspaceSlug },
);
// Redirect to 404 if workspace doesn't exist
useEffect(() => {
if (router.isReady && workspaceSlug && !isLoading) {
if (error?.data?.code === "NOT_FOUND" || (!data && !isLoading)) {
router.replace("/404");
}
}
}, [router, workspaceSlug, isLoading, error, data]);
const BoardsList = ({
isLoading,
boards,

View File

@@ -1,13 +1,9 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
import { z } from "zod";
import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal";
import Input from "~/components/Input";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
@@ -15,28 +11,10 @@ import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
const githubTokenSchema = z.object({
token: z.string().min(1, { message: t`Token is required` }),
});
type GitHubTokenFormValues = z.infer<typeof githubTokenSchema>;
export default function IntegrationsSettings() {
const { modalContentType, isOpen } = useModal();
const { showPopup } = usePopup();
const {
register,
handleSubmit,
formState: { isDirty, errors },
reset,
} = useForm<GitHubTokenFormValues>({
resolver: zodResolver(githubTokenSchema),
defaultValues: {
token: "",
},
});
const {
data: integrations,
refetch: refetchIntegrations,
@@ -56,25 +34,21 @@ export default function IntegrationsSettings() {
},
);
const { data: githubStatus, refetch: refetchGithubStatus } =
api.integration.getGitHubStatus.useQuery();
useEffect(() => {
const handleFocus = () => {
void refetchIntegrations();
void refetchGithubStatus();
refetchIntegrations();
};
window.addEventListener("focus", handleFocus);
return () => {
window.removeEventListener("focus", handleFocus);
};
}, [refetchIntegrations, refetchGithubStatus]);
}, [refetchIntegrations]);
const { mutateAsync: disconnectTrello } =
api.integration.disconnect.useMutation({
onSuccess: () => {
void refetchIntegrations();
void refetchTrelloUrl();
refetchIntegrations();
refetchTrelloUrl();
showPopup({
header: t`Trello disconnected`,
message: t`Your Trello account has been disconnected.`,
@@ -90,49 +64,6 @@ export default function IntegrationsSettings() {
},
});
const { mutateAsync: saveGithubToken, isPending: isSavingGithubToken } =
api.integration.saveGitHubToken.useMutation({
onSuccess: () => {
void refetchGithubStatus();
reset();
showPopup({
header: t`GitHub connected`,
message: t`Your GitHub account has been connected.`,
icon: "success",
});
},
onError: () => {
showPopup({
header: t`Error connecting GitHub`,
message: t`An error occurred while connecting your GitHub account.`,
icon: "error",
});
},
});
const onSubmitGithubToken = (data: GitHubTokenFormValues) => {
void saveGithubToken({ token: data.token });
};
const { mutateAsync: disconnectGithub } =
api.integration.disconnectGitHub.useMutation({
onSuccess: () => {
void refetchGithubStatus();
showPopup({
header: t`GitHub disconnected`,
message: t`Your GitHub account has been disconnected.`,
icon: "success",
});
},
onError: () => {
showPopup({
header: t`Error disconnecting GitHub`,
message: t`An error occurred while disconnecting your GitHub account.`,
icon: "error",
});
},
});
return (
<>
<PageHead title={t`Settings | Integrations`} />
@@ -181,51 +112,6 @@ export default function IntegrationsSettings() {
)}
</div>
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`GitHub`}
</h2>
{!githubStatus?.connected ? (
<>
<p className="mb-4 text-sm text-neutral-500 dark:text-dark-900">
{t`Connect your GitHub account to import projects.`}
</p>
<form
onSubmit={handleSubmit(onSubmitGithubToken)}
className="flex gap-2"
>
<div className="mb-4 flex w-full max-w-[325px] items-center gap-2">
<Input
type="password"
placeholder="Personal Access Token"
{...register("token")}
errorMessage={errors.token?.message}
/>
</div>
<div>
<Button
variant="secondary"
type="submit"
disabled={!isDirty || isSavingGithubToken}
isLoading={isSavingGithubToken}
>
{t`Connect GitHub`}
</Button>
</div>
</form>
</>
) : (
<>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`Your GitHub account is connected.`}
</p>
<Button variant="secondary" onClick={() => disconnectGithub()}>
{t`Disconnect GitHub`}
</Button>
</>
)}
</div>
{/* Global modals */}
<Modal
modalSize="md"

View File

@@ -1,78 +0,0 @@
import { t } from "@lingui/core/macro";
import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { DeleteWebhookConfirmation } from "./components/DeleteWebhookConfirmation";
import { NewWebhookModal } from "./components/NewWebhookModal";
import WebhookList from "./components/WebhookList";
export default function WebhookSettings() {
const { modalContentType, openModal, isOpen } = useModal();
const { workspace } = useWorkspace();
if (!workspace) {
return null;
}
return (
<>
<PageHead title={t`Settings | Webhooks`} />
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Webhooks`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`Configure webhooks to receive notifications when cards are created, updated, moved, or deleted.`}
</p>
<div className="mb-4 flex items-center justify-between">
<Button variant="primary" onClick={() => openModal("NEW_WEBHOOK")}>
{t`Add webhook`}
</Button>
</div>
<WebhookList workspacePublicId={workspace.publicId} />
</div>
{/* Webhook-specific modals */}
<Modal
modalSize="md"
isVisible={isOpen && modalContentType === "NEW_WEBHOOK"}
>
<NewWebhookModal workspacePublicId={workspace.publicId} />
</Modal>
<Modal
modalSize="md"
isVisible={isOpen && modalContentType === "EDIT_WEBHOOK"}
>
<NewWebhookModal workspacePublicId={workspace.publicId} isEdit />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_WEBHOOK"}
>
<DeleteWebhookConfirmation workspacePublicId={workspace.publicId} />
</Modal>
{/* Global modals */}
<Modal
modalSize="md"
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"}
>
<FeedbackModal />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
>
<NewWorkspaceForm />
</Modal>
</>
);
}

View File

@@ -21,7 +21,6 @@ import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescript
import UpdateWorkspaceEmailVisibilityForm from "./components/UpdateWorkspaceEmailVisibilityForm";
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
import UpdateWeekStartDayForm from "./components/UpdateWeekStartDayForm";
import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation";
export default function WorkspaceSettings() {
@@ -87,15 +86,6 @@ export default function WorkspaceSettings() {
disabled={!canEditWorkspace}
/>
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Week start day`}
</h2>
<UpdateWeekStartDayForm
workspacePublicId={workspace.publicId}
weekStartDay={workspaceData?.weekStartDay ?? 1}
disabled={!canEditWorkspace}
/>
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Email visibility`}
</h2>

View File

@@ -1,78 +0,0 @@
import { t } from "@lingui/core/macro";
import { HiXMark } from "react-icons/hi2";
import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
interface DeleteWebhookConfirmationProps {
workspacePublicId: string;
}
export function DeleteWebhookConfirmation({
workspacePublicId,
}: DeleteWebhookConfirmationProps) {
const { closeModal, entityId: webhookPublicId, entityLabel: webhookName } = useModal();
const { showPopup } = usePopup();
const utils = api.useUtils();
const deleteWebhookMutation = api.webhook.delete.useMutation({
onSuccess: () => {
void utils.webhook.list.invalidate({ workspacePublicId });
showPopup({ message: t`Webhook deleted successfully`, type: "success" });
closeModal();
},
onError: (error) => {
showPopup({
message: error.message || t`Failed to delete webhook`,
type: "error",
});
},
});
const handleDelete = () => {
if (!webhookPublicId) return;
deleteWebhookMutation.mutate({
workspacePublicId,
webhookPublicId: webhookPublicId as string,
});
};
return (
<div>
<div className="px-5 pt-5">
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
<h2 className="text-sm font-bold">{t`Delete webhook`}</h2>
<button
type="button"
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
closeModal();
}}
>
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
</button>
</div>
<p className="text-sm text-neutral-500 dark:text-dark-900">
{t`Are you sure you want to delete the webhook "${webhookName}"? This action cannot be undone.`}
</p>
</div>
<div className="mt-8 flex items-center justify-end gap-3 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Button variant="secondary" onClick={() => closeModal()}>
{t`Cancel`}
</Button>
<Button
variant="danger"
onClick={handleDelete}
isLoading={deleteWebhookMutation.isPending}
>
{t`Delete`}
</Button>
</div>
</div>
);
}

View File

@@ -1,322 +0,0 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
import { z } from "zod";
import { webhookEvents } from "@kan/db/schema";
import Button from "~/components/Button";
import Input from "~/components/Input";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
const newWebhookSchema = z.object({
name: z
.string()
.min(1, { message: t`Webhook name is required` })
.max(255, { message: t`Webhook name cannot exceed 255 characters` }),
url: z
.string()
.min(1, { message: t`Webhook URL is required` })
.url({ message: t`Please enter a valid URL` })
.max(2048, { message: t`URL cannot exceed 2048 characters` }),
secret: z
.string()
.max(512, { message: t`Secret cannot exceed 512 characters` })
.optional(),
events: z
.array(z.enum(webhookEvents))
.min(1, { message: t`Select at least one event` }),
active: z.boolean(),
});
type WebhookFormData = z.infer<typeof newWebhookSchema>;
interface NewWebhookModalProps {
workspacePublicId: string;
isEdit?: boolean;
}
export function NewWebhookModal({
workspacePublicId,
isEdit = false,
}: NewWebhookModalProps) {
const { closeModal, entityId: webhookPublicId, getModalState, clearModalState } = useModal();
const { showPopup } = usePopup();
const [isTestingWebhook, setIsTestingWebhook] = useState(false);
const modalState = isEdit ? getModalState("EDIT_WEBHOOK") : null;
const utils = api.useUtils();
const {
register,
handleSubmit,
control,
reset,
formState: { errors },
} = useForm<WebhookFormData>({
resolver: zodResolver(newWebhookSchema),
defaultValues: {
name: "",
url: "",
secret: "",
events: [...webhookEvents],
active: true,
},
});
useEffect(() => {
if (isEdit && webhookPublicId && modalState) {
reset({
name: modalState.name ?? "",
url: modalState.url ?? "",
secret: "",
events: modalState.events ?? ["card.created"],
active: modalState.active ?? true,
});
} else if (!isEdit) {
reset({
name: "",
url: "",
secret: "",
events: [...webhookEvents],
active: true,
});
}
}, [isEdit, webhookPublicId, modalState, reset]);
// Clear modal state when closing
useEffect(() => {
return () => {
if (isEdit) {
clearModalState("EDIT_WEBHOOK");
}
};
}, [isEdit, clearModalState]);
const createWebhookMutation = api.webhook.create.useMutation({
onSuccess: () => {
void utils.webhook.list.invalidate({ workspacePublicId });
showPopup({ message: t`Webhook created successfully`, type: "success" });
closeModal();
},
onError: (error) => {
showPopup({
message: error.message || t`Failed to create webhook`,
type: "error",
});
},
});
const updateWebhookMutation = api.webhook.update.useMutation({
onSuccess: () => {
void utils.webhook.list.invalidate({ workspacePublicId });
showPopup({ message: t`Webhook updated successfully`, type: "success" });
closeModal();
},
onError: (error) => {
showPopup({
message: error.message || t`Failed to update webhook`,
type: "error",
});
},
});
const testWebhookMutation = api.webhook.test.useMutation({
onSuccess: (result) => {
if (result.success) {
showPopup({ message: t`Test webhook sent successfully!`, type: "success" });
} else {
showPopup({
message: result.error || t`Webhook test failed`,
type: "error",
});
}
setIsTestingWebhook(false);
},
onError: (error) => {
showPopup({
message: error.message || t`Failed to test webhook`,
type: "error",
});
setIsTestingWebhook(false);
},
});
const onSubmit = (data: WebhookFormData) => {
if (isEdit && webhookPublicId) {
updateWebhookMutation.mutate({
workspacePublicId,
webhookPublicId: webhookPublicId as string,
name: data.name,
url: data.url,
secret: data.secret || undefined,
events: data.events,
active: data.active,
});
} else {
createWebhookMutation.mutate({
workspacePublicId,
name: data.name,
url: data.url,
secret: data.secret || undefined,
events: data.events,
});
}
};
const handleTestWebhook = () => {
if (!webhookPublicId) return;
setIsTestingWebhook(true);
testWebhookMutation.mutate({
workspacePublicId,
webhookPublicId: webhookPublicId as string,
});
};
const isPending = createWebhookMutation.isPending || updateWebhookMutation.isPending;
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5">
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
<h2 className="text-sm font-bold">
{isEdit ? t`Edit webhook` : t`New webhook`}
</h2>
<button
type="button"
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
closeModal();
}}
>
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
{t`Name`}
</label>
<Input
id="name"
placeholder={t`My webhook`}
{...register("name")}
errorMessage={errors.name?.message}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
{t`URL`}
</label>
<Input
id="url"
placeholder="https://example.com/webhook"
{...register("url")}
errorMessage={errors.url?.message}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
{t`Secret (optional)`}
</label>
<Input
id="secret"
type="password"
placeholder={isEdit ? t`Enter new secret to update` : t`HMAC secret for signature verification`}
{...register("secret")}
errorMessage={errors.secret?.message}
/>
<p className="mt-1 text-xs text-neutral-500 dark:text-dark-800">
{t`Used to sign webhook payloads for verification. Leave blank to keep existing secret.`}
</p>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-light-900 dark:text-dark-900">
{t`Events`}
</label>
<Controller
name="events"
control={control}
render={({ field }) => (
<div className="space-y-2">
{webhookEvents.map((event) => (
<label
key={event}
className="flex items-center space-x-2 cursor-pointer"
>
<input
type="checkbox"
checked={field.value.includes(event)}
onChange={(e) => {
if (e.target.checked) {
field.onChange([...field.value, event]);
} else {
field.onChange(
field.value.filter((v) => v !== event)
);
}
}}
className="h-4 w-4 rounded border-light-400 text-primary-600 focus:ring-primary-500 dark:border-dark-400"
/>
<span className="text-sm text-light-900 dark:text-dark-900">
{event}
</span>
</label>
))}
</div>
)}
/>
{errors.events && (
<p className="mt-1 text-xs text-red-500">{errors.events.message}</p>
)}
</div>
{isEdit && (
<div>
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
{...register("active")}
className="h-4 w-4 rounded border-light-400 text-primary-600 focus:ring-primary-500 dark:border-dark-400"
/>
<span className="text-sm text-light-900 dark:text-dark-900">
{t`Active`}
</span>
</label>
</div>
)}
</div>
</div>
<div className="mt-8 flex items-center justify-between border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div>
{isEdit && webhookPublicId && (
<Button
type="button"
variant="secondary"
onClick={handleTestWebhook}
isLoading={isTestingWebhook}
>
{t`Send test`}
</Button>
)}
</div>
<div>
<Button type="submit" isLoading={isPending}>
{isEdit ? t`Save changes` : t`Create webhook`}
</Button>
</div>
</div>
</form>
);
}

View File

@@ -1,59 +0,0 @@
import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import { api } from "~/utils/api";
export default function UpdateWeekStartDayForm({
workspacePublicId,
weekStartDay,
disabled = false,
}: {
workspacePublicId: string;
weekStartDay: number;
disabled?: boolean;
}) {
const utils = api.useUtils();
const [value, setValue] = useState(weekStartDay);
useEffect(() => {
setValue(weekStartDay);
}, [weekStartDay]);
const updateWorkspace = api.workspace.update.useMutation({
onSuccess: () => {
if (workspacePublicId && workspacePublicId.length >= 12) {
void utils.workspace.byId.invalidate({
workspacePublicId,
});
void utils.workspace.all.invalidate();
}
},
});
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
if (disabled) return;
const newValue = Number(e.target.value);
setValue(newValue);
updateWorkspace.mutate({
workspacePublicId,
weekStartDay: newValue,
});
};
return (
<div className="flex gap-2">
<div className="mb-4 flex w-full max-w-[325px] items-center gap-2">
<select
value={value}
onChange={handleChange}
disabled={disabled || updateWorkspace.isPending}
className="block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-sm shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:leading-6"
>
<option value={0}>{t`Sunday`}</option>
<option value={1}>{t`Monday`}</option>
<option value={6}>{t`Saturday`}</option>
</select>
</div>
</div>
);
}

View File

@@ -33,7 +33,7 @@ const UpdateWorkspaceUrlForm = ({
.min(3, {
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-]+$/, {
message: t`URL can only contain letters, numbers, and hyphens`,
}),

View File

@@ -1,295 +0,0 @@
import { t } from "@lingui/core/macro";
import type { Locale as DateFnsLocale } from "date-fns";
import { format } from "date-fns";
import { HiEllipsisHorizontal } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import Dropdown from "~/components/Dropdown";
import { useLocalisation } from "~/hooks/useLocalisation";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
interface TableRowProps {
publicId?: string;
name?: string;
url?: string;
events?: string[];
active?: boolean;
createdAt?: Date | null;
dateLocale?: DateFnsLocale;
isLastRow?: boolean;
showSkeleton?: boolean;
onEdit?: () => void;
onTest?: () => void;
onDelete?: () => void;
}
function formatEvents(events: string[]) {
return events
.map((e) => e.replace("card.", ""))
.join(", ");
}
function formatDate(date?: Date | null, locale?: DateFnsLocale) {
if (!date) return "Never";
return format(date, "MMM d, yyyy", { locale });
}
function TableRow({
publicId,
name,
url,
events,
active,
createdAt,
dateLocale,
isLastRow,
showSkeleton,
onEdit,
onTest,
onDelete,
}: TableRowProps) {
return (
<tr className="rounded-b-lg">
<td className={twMerge("w-[25%]", isLastRow ? "rounded-bl-lg" : "")}>
<div className="flex items-center p-4">
<div className="ml-2 min-w-0 flex-1">
<div className="flex items-center">
<p
className={twMerge(
"mr-2 text-sm font-medium text-light-900 dark:text-dark-900",
showSkeleton &&
"mb-2 h-3 w-[125px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
)}
>
{name}
</p>
</div>
</div>
</div>
</td>
<td className="w-[30%] px-3 py-4">
<p
className={twMerge(
"truncate text-sm text-light-900 dark:text-dark-900",
showSkeleton &&
"h-3 w-[180px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
)}
title={url}
>
{url}
</p>
</td>
<td className="w-[20%] px-3 py-4">
<p
className={twMerge(
"text-sm text-light-900 dark:text-dark-900",
showSkeleton &&
"h-3 w-[100px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
)}
>
{events && formatEvents(events)}
</p>
</td>
<td className="w-[10%] px-3 py-4">
<span
className={twMerge(
"inline-flex items-center rounded-md px-1.5 py-0.5 text-[11px] font-medium ring-1 ring-inset",
active
? "bg-emerald-500/10 text-emerald-400 ring-emerald-500/20"
: "bg-gray-500/10 text-gray-400 ring-gray-500/20",
showSkeleton &&
"h-5 w-[50px] animate-pulse bg-light-200 ring-0 dark:bg-dark-200",
)}
>
{!showSkeleton && (active ? t`Active` : t`Inactive`)}
</span>
</td>
<td className="w-[10%] px-3 py-4">
<p
className={twMerge(
"text-sm text-light-900 dark:text-dark-900",
showSkeleton &&
"h-3 w-[80px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
)}
>
{formatDate(createdAt, dateLocale)}
</p>
</td>
<td
className={twMerge(
"w-[5%] min-w-[50px]",
isLastRow && "rounded-br-lg",
)}
>
{!showSkeleton && (
<div className="flex w-full items-center justify-center px-3">
<div className="relative z-50">
<Dropdown
items={[
{
label: t`Edit`,
action: () => onEdit?.(),
},
{
label: t`Test`,
action: () => onTest?.(),
},
{
label: t`Delete`,
action: () => onDelete?.(),
},
]}
>
<HiEllipsisHorizontal
size={25}
className="text-light-900 dark:text-dark-900"
/>
</Dropdown>
</div>
</div>
)}
</td>
</tr>
);
}
interface WebhookListProps {
workspacePublicId: string;
}
export default function WebhookList({ workspacePublicId }: WebhookListProps) {
const { openModal, setModalState } = useModal();
const { showPopup } = usePopup();
const { dateLocale } = useLocalisation();
const { data: webhooks, isLoading } = api.webhook.list.useQuery({
workspacePublicId,
});
const testWebhookMutation = api.webhook.test.useMutation({
onSuccess: (result) => {
if (result.success) {
showPopup({ message: t`Test webhook sent successfully!`, type: "success" });
} else {
showPopup({
message: result.error || t`Webhook test failed`,
type: "error",
});
}
},
onError: (error) => {
showPopup({
message: error.message || t`Failed to test webhook`,
type: "error",
});
},
});
if (!isLoading && (!webhooks || webhooks.length === 0)) {
return (
<div className="rounded-lg border border-light-300 bg-light-50 p-8 text-center dark:border-dark-300 dark:bg-dark-100">
<p className="text-sm text-neutral-500 dark:text-dark-900">
{t`No webhooks configured. Add a webhook to receive notifications.`}
</p>
</div>
);
}
return (
<div className="mt-8 flow-root">
<div className="overflow-x-auto overflow-y-visible">
<div className="inline-block min-w-full py-2 pb-12 align-middle">
<div className="relative h-full shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
<table className="min-w-[700px] divide-y divide-light-600 dark:divide-dark-600">
<thead className="rounded-t-lg bg-light-300 dark:bg-dark-200">
<tr>
<th
scope="col"
className="w-[25%] rounded-tl-lg py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-light-900 dark:text-dark-900 sm:pl-6"
>
{t`Name`}
</th>
<th
scope="col"
className="w-[30%] px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
>
{t`URL`}
</th>
<th
scope="col"
className="w-[20%] px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
>
{t`Events`}
</th>
<th
scope="col"
className="w-[10%] px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
>
{t`Status`}
</th>
<th
scope="col"
className="w-[10%] px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
>
{t`Created`}
</th>
<th
scope="col"
className="w-[5%] rounded-tr-lg px-3 py-3.5 text-center text-sm font-semibold text-light-900 dark:text-dark-900"
>
{/* Actions column */}
</th>
</tr>
</thead>
<tbody className="divide-y divide-light-600 bg-light-50 dark:divide-dark-600 dark:bg-dark-100">
{!isLoading &&
webhooks?.map((webhook, index) => (
<TableRow
key={webhook.publicId}
publicId={webhook.publicId}
name={webhook.name}
url={webhook.url}
events={webhook.events}
active={webhook.active}
createdAt={webhook.createdAt}
dateLocale={dateLocale}
isLastRow={index === webhooks.length - 1}
onEdit={() => {
setModalState("EDIT_WEBHOOK", {
publicId: webhook.publicId,
name: webhook.name,
url: webhook.url,
events: webhook.events,
active: webhook.active,
});
openModal("EDIT_WEBHOOK", webhook.publicId, webhook.name);
}}
onTest={() => {
testWebhookMutation.mutate({
workspacePublicId,
webhookPublicId: webhook.publicId,
});
}}
onDelete={() => {
openModal("DELETE_WEBHOOK", webhook.publicId, webhook.name);
}}
/>
))}
{isLoading && (
<>
<TableRow showSkeleton />
<TableRow showSkeleton />
<TableRow showSkeleton isLastRow />
</>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,17 +1,4 @@
services:
migrator:
image: ghcr.io/kanbn/kan-migrate:latest
container_name: ${MIGRATOR_CONTAINER_NAME:-kan-migrate}
build:
context: ..
dockerfile: apps/web/Dockerfile
target: migrate
networks:
- dokploy-network
environment:
- POSTGRES_URL=${POSTGRES_URL}
restart: "no"
web:
image: ghcr.io/kanbn/kan:latest
container_name: ${CONTAINER_NAME:-kan-web}
@@ -22,7 +9,6 @@ services:
build:
context: ..
dockerfile: apps/web/Dockerfile
target: web
args:
APP_VERSION: ${APP_VERSION:-}
env_file:
@@ -37,9 +23,6 @@ services:
- NEXT_PUBLIC_USE_STANDALONE_OUTPUT=${NEXT_PUBLIC_USE_STANDALONE_OUTPUT}
- REDIS_URL=${REDIS_URL}
# Logging (optional - debug, info, warn, error; defaults to debug in dev, info in prod)
- LOG_LEVEL=${LOG_LEVEL}
# Stripe
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
@@ -69,7 +52,6 @@ services:
- S3_REGION=${S3_REGION}
- S3_ENDPOINT=${S3_ENDPOINT}
- S3_FORCE_PATH_STYLE=${S3_FORCE_PATH_STYLE}
- S3_AVATAR_UPLOAD_LIMIT=${S3_AVATAR_UPLOAD_LIMIT}
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME}
@@ -97,9 +79,6 @@ services:
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST}
depends_on:
migrator:
condition: service_completed_successfully
networks:
dokploy-network:

View File

@@ -1,20 +1,4 @@
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}
@@ -22,10 +6,10 @@ services:
- "${WEB_PORT:-3000}:3000"
networks:
- kan-network
- dokploy-network
build:
context: .
dockerfile: ./apps/web/Dockerfile
target: web
dockerfile: ./apps/web/Dockerfile.new
env_file:
- .env
environment:
@@ -37,9 +21,6 @@ services:
# Redis (optional - for rate limiting)
- REDIS_URL=${REDIS_URL}
# Logging (optional - debug, info, warn, error; defaults to debug in dev, info in prod)
- LOG_LEVEL=${LOG_LEVEL}
# Admin API key (optional)
- KAN_ADMIN_API_KEY=${KAN_ADMIN_API_KEY}
@@ -60,7 +41,6 @@ services:
- S3_REGION=${S3_REGION}
- S3_ENDPOINT=${S3_ENDPOINT}
- S3_FORCE_PATH_STYLE=${S3_FORCE_PATH_STYLE}
- S3_AVATAR_UPLOAD_LIMIT=${S3_AVATAR_UPLOAD_LIMIT}
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME}
@@ -127,8 +107,7 @@ services:
- APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET}
- APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER}
depends_on:
migrate:
condition: service_completed_successfully
- postgres
restart: unless-stopped
postgres:
@@ -142,17 +121,14 @@ 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

@@ -1,75 +0,0 @@
import { PGlite } from "@electric-sql/pglite";
import { uuid_ossp } from "@electric-sql/pglite/contrib/uuid_ossp";
import { pg_trgm } from "@electric-sql/pglite/contrib/pg_trgm";
import { drizzle } from "drizzle-orm/pglite";
import { migrate } from "drizzle-orm/pglite/migrator";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import type { Pool } from "pg";
import * as schema from "@kan/db/schema";
export type TestDbClient = NodePgDatabase<typeof schema> & {
$client: Pool;
};
/**
* Creates a fresh in-memory PGlite database for testing.
* Each call returns an isolated database instance with migrations applied.
*/
export async function createTestDb(): Promise<TestDbClient> {
const client = new PGlite({
extensions: { uuid_ossp, pg_trgm },
});
const db = drizzle(client, { schema });
// Run migrations
await migrate(db, { migrationsFolder: "../../packages/db/migrations" });
return db as unknown as TestDbClient;
}
/**
* Seeds a test database with a workspace and user for testing.
* Returns the created entities for use in tests.
*/
export async function seedTestData(db: TestDbClient) {
// Create a test user
const [user] = await db
.insert(schema.users)
.values({
id: crypto.randomUUID(),
name: "Test User",
email: "test@example.com",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
// Create a test workspace (publicId must be exactly 12 chars)
const [workspace] = await db
.insert(schema.workspaces)
.values({
publicId: "wstest123456",
name: "Test Workspace",
slug: "test-workspace",
ownerId: user!.id,
createdAt: new Date(),
})
.returning();
// Add user as admin member of workspace
await db.insert(schema.workspaceMembers).values({
publicId: "wm1234567890",
email: user!.email,
workspaceId: workspace!.id,
userId: user!.id,
createdBy: user!.id,
role: "admin",
status: "active",
createdAt: new Date(),
});
return { user: user!, workspace: workspace! };
}

View File

@@ -1,242 +0,0 @@
import { describe, it, expect, beforeEach } from "vitest";
import * as webhookRepo from "@kan/db/repository/webhook.repo";
import { createTestDb, seedTestData, type TestDbClient } from "./test-db";
describe("webhook repository integration tests", () => {
let db: TestDbClient;
let testUser: { id: string; name: string | null };
let testWorkspace: { id: number; publicId: string };
beforeEach(async () => {
db = await createTestDb();
const seeded = await seedTestData(db);
testUser = seeded.user;
testWorkspace = seeded.workspace;
});
describe("create", () => {
it("creates a webhook with all fields", async () => {
const webhook = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "My Webhook",
url: "https://example.com/webhook",
secret: "my-secret",
events: ["card.created", "card.updated"],
createdBy: testUser.id,
});
expect(webhook).not.toBeNull();
expect(webhook!.name).toBe("My Webhook");
expect(webhook!.url).toBe("https://example.com/webhook");
expect(webhook!.events).toEqual(["card.created", "card.updated"]);
expect(webhook!.active).toBe(true);
expect(webhook!.publicId).toMatch(/^[a-zA-Z0-9]{12}$/);
});
it("creates a webhook without secret", async () => {
const webhook = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "No Secret Webhook",
url: "https://example.com/webhook",
events: ["card.deleted"],
createdBy: testUser.id,
});
expect(webhook).not.toBeNull();
expect(webhook!.name).toBe("No Secret Webhook");
});
});
describe("getByPublicId", () => {
it("retrieves a webhook by public ID", async () => {
const created = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Test Webhook",
url: "https://example.com/webhook",
events: ["card.created"],
createdBy: testUser.id,
});
const retrieved = await webhookRepo.getByPublicId(db, created!.publicId);
expect(retrieved).not.toBeNull();
expect(retrieved!.publicId).toBe(created!.publicId);
expect(retrieved!.name).toBe("Test Webhook");
});
it("returns null for non-existent public ID", async () => {
const retrieved = await webhookRepo.getByPublicId(db, "nonexistent12");
expect(retrieved).toBeNull();
});
});
describe("getAllByWorkspaceId", () => {
it("returns all webhooks for a workspace", async () => {
await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Webhook 1",
url: "https://example.com/webhook1",
events: ["card.created"],
createdBy: testUser.id,
});
await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Webhook 2",
url: "https://example.com/webhook2",
events: ["card.updated"],
createdBy: testUser.id,
});
const webhooks = await webhookRepo.getAllByWorkspaceId(db, testWorkspace.id);
expect(webhooks).toHaveLength(2);
expect(webhooks.map((w) => w.name)).toContain("Webhook 1");
expect(webhooks.map((w) => w.name)).toContain("Webhook 2");
});
it("returns empty array for workspace with no webhooks", async () => {
const webhooks = await webhookRepo.getAllByWorkspaceId(db, testWorkspace.id);
expect(webhooks).toEqual([]);
});
});
describe("getActiveByWorkspaceId", () => {
it("returns only active webhooks", async () => {
const active = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Active Webhook",
url: "https://example.com/active",
events: ["card.created"],
createdBy: testUser.id,
});
const inactive = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Inactive Webhook",
url: "https://example.com/inactive",
events: ["card.created"],
createdBy: testUser.id,
});
// Deactivate one webhook
await webhookRepo.update(db, inactive!.publicId, { active: false });
const activeWebhooks = await webhookRepo.getActiveByWorkspaceId(db, testWorkspace.id);
expect(activeWebhooks).toHaveLength(1);
// getActiveByWorkspaceId returns only publicId, url, secret, events
expect(activeWebhooks[0]!.url).toBe("https://example.com/active");
expect(activeWebhooks[0]!.publicId).toBe(active!.publicId);
});
});
describe("update", () => {
it("updates webhook name", async () => {
const created = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Original Name",
url: "https://example.com/webhook",
events: ["card.created"],
createdBy: testUser.id,
});
const updated = await webhookRepo.update(db, created!.publicId, {
name: "Updated Name",
});
expect(updated).not.toBeNull();
expect(updated!.name).toBe("Updated Name");
expect(updated!.url).toBe("https://example.com/webhook"); // Unchanged
});
it("updates webhook events", async () => {
const created = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Test Webhook",
url: "https://example.com/webhook",
events: ["card.created"],
createdBy: testUser.id,
});
const updated = await webhookRepo.update(db, created!.publicId, {
events: ["card.created", "card.updated", "card.deleted"],
});
expect(updated!.events).toEqual(["card.created", "card.updated", "card.deleted"]);
});
it("updates webhook active status", async () => {
const created = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Test Webhook",
url: "https://example.com/webhook",
events: ["card.created"],
createdBy: testUser.id,
});
expect(created!.active).toBe(true);
const updated = await webhookRepo.update(db, created!.publicId, {
active: false,
});
expect(updated!.active).toBe(false);
});
it("sets updatedAt timestamp on update", async () => {
const created = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Test Webhook",
url: "https://example.com/webhook",
events: ["card.created"],
createdBy: testUser.id,
});
// create() doesn't return updatedAt, verify via getByPublicId
const initial = await webhookRepo.getByPublicId(db, created!.publicId);
expect(initial!.updatedAt).toBeNull();
const updated = await webhookRepo.update(db, created!.publicId, {
name: "Updated",
});
expect(updated!.updatedAt).not.toBeNull();
expect(updated!.updatedAt).toBeInstanceOf(Date);
});
it("returns null for non-existent webhook", async () => {
const updated = await webhookRepo.update(db, "nonexistent12", {
name: "Updated",
});
expect(updated).toBeNull();
});
});
describe("hardDelete", () => {
it("deletes a webhook permanently", async () => {
const created = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "To Be Deleted",
url: "https://example.com/webhook",
events: ["card.created"],
createdBy: testUser.id,
});
await webhookRepo.hardDelete(db, created!.publicId);
const retrieved = await webhookRepo.getByPublicId(db, created!.publicId);
expect(retrieved).toBeNull();
});
it("does not throw for non-existent webhook", async () => {
await expect(
webhookRepo.hardDelete(db, "nonexistent12"),
).resolves.not.toThrow();
});
});
});

View File

@@ -40,15 +40,12 @@
"dev": "tsc",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
},
"dependencies": {
"@kan/auth": "workspace:*",
"@kan/db": "workspace:*",
"@kan/email": "workspace:^",
"@kan/logger": "workspace:^",
"@kan/shared": "workspace:^",
"@kan/stripe": "workspace:^",
"@trpc/server": "catalog:",
@@ -63,8 +60,7 @@
"@kan/tsconfig": "workspace:*",
"eslint": "catalog:",
"prettier": "catalog:",
"typescript": "catalog:",
"vitest": "^3.0.0"
"typescript": "catalog:"
},
"prettier": "@kan/prettier-config"
}

View File

@@ -11,7 +11,6 @@ import { listRouter } from "./routers/list";
import { memberRouter } from "./routers/member";
import { permissionRouter } from "./routers/permission";
import { userRouter } from "./routers/user";
import { webhookRouter } from "./routers/webhook";
import { workspaceRouter } from "./routers/workspace";
import { createTRPCRouter } from "./trpc";
@@ -28,7 +27,6 @@ export const appRouter = createTRPCRouter({
import: importRouter,
permission: permissionRouter,
user: userRouter,
webhook: webhookRouter,
workspace: workspaceRouter,
integration: integrationRouter,
});

View File

@@ -34,7 +34,6 @@ export const boardRouter = createTRPCRouter({
z.object({
workspacePublicId: z.string().min(12),
type: z.enum(["regular", "template"]).optional(),
archived: z.boolean().optional(),
}),
)
.output(
@@ -66,10 +65,7 @@ export const boardRouter = createTRPCRouter({
ctx.db,
workspace.id,
userId,
{
type: input.type,
archived: input.archived ?? false,
}
{ type: input.type }
);
return result;
@@ -157,24 +153,24 @@ export const boardRouter = createTRPCRouter({
// Generate presigned URLs for workspace member avatars
const workspaceWithAvatarUrls = result.workspace
? {
...result.workspace,
members: await Promise.all(
result.workspace.members.map(async (member) => {
if (!member.user?.image) {
return member;
}
...result.workspace,
members: await Promise.all(
result.workspace.members.map(async (member) => {
if (!member.user?.image) {
return member;
}
const avatarUrl = await generateAvatarUrl(member.user.image);
return {
...member,
user: {
...member.user,
image: avatarUrl,
},
};
}),
),
}
const avatarUrl = await generateAvatarUrl(member.user.image);
return {
...member,
user: {
...member.user,
image: avatarUrl,
},
};
}),
),
}
: result.workspace;
// Generate presigned URLs for card member avatars
@@ -222,7 +218,7 @@ export const boardRouter = createTRPCRouter({
workspaceSlug: z
.string()
.min(3)
.max(64)
.max(24)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
boardSlug: z
.string()
@@ -465,8 +461,7 @@ export const boardRouter = createTRPCRouter({
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
.optional(),
visibility: z.enum(["public", "private"]).optional(),
favorite: z.boolean().optional(),
isArchived: z.boolean().optional(),
favorite: z.boolean().optional()
}),
)
.output(z.object({ success: z.boolean() }).or(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>()))
@@ -508,7 +503,7 @@ export const boardRouter = createTRPCRouter({
}
// Handle other updates (name, slug, visibility)
const hasOtherUpdates = input.name || input.slug || input.visibility !== undefined || input.isArchived !== undefined;
const hasOtherUpdates = input.name || input.slug || input.visibility !== undefined;
if (!hasOtherUpdates) {
// Only favorite was updated, return success
@@ -535,7 +530,6 @@ export const boardRouter = createTRPCRouter({
slug: input.slug,
boardPublicId: input.boardPublicId,
visibility: input.visibility,
isArchived: input.isArchived,
});
if (!result)

View File

@@ -13,10 +13,6 @@ import { mergeActivities } from "../utils/activities";
import { sendMentionEmails } from "../utils/notifications";
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
import { generateAttachmentUrl, generateAvatarUrl } from "@kan/shared/utils";
import {
createCardWebhookPayload,
sendWebhooksForWorkspace,
} from "../utils/webhook";
export const cardRouter = createTRPCRouter({
create: protectedProcedure
@@ -169,32 +165,6 @@ export const cardRouter = createTRPCRouter({
});
}
// Fire webhooks (non-blocking)
sendWebhooksForWorkspace(
ctx.db,
list.workspaceId,
createCardWebhookPayload(
"card.created",
{
id: String(newCard.id),
title: input.title,
description: input.description,
dueDate: input.dueDate ?? null,
listId: String(newCard.listId),
},
{
boardId: list.boardPublicId,
boardName: list.boardName,
listName: list.name,
user: ctx.user
? { id: ctx.user.id, name: ctx.user.name }
: undefined,
},
),
).catch((error) => {
console.error("Webhook delivery failed:", error);
});
return newCard;
}),
addComment: protectedProcedure
@@ -1037,59 +1007,6 @@ export const cardRouter = createTRPCRouter({
await cardActivityRepo.bulkCreate(ctx.db, activities);
}
// Build changes object for webhook
const webhookChanges: Record<string, { from: unknown; to: unknown }> = {};
if (input.title && existingCard.title !== input.title) {
webhookChanges.title = { from: existingCard.title, to: input.title };
}
if (input.description && existingCard.description !== input.description) {
webhookChanges.description = {
from: existingCard.description,
to: input.description,
};
}
if (
input.dueDate !== undefined &&
previousDueDate?.getTime() !== input.dueDate?.getTime()
) {
webhookChanges.dueDate = { from: previousDueDate, to: input.dueDate };
}
if (newListId && existingCard.listId !== newListId) {
webhookChanges.listId = { from: existingCard.listId, to: newListId };
}
// Fire webhooks (non-blocking)
sendWebhooksForWorkspace(
ctx.db,
card.workspaceId,
createCardWebhookPayload(
newListId && existingCard.listId !== newListId
? "card.moved"
: "card.updated",
{
id: String(result.id),
title: result.title,
description: result.description,
dueDate: result.dueDate,
listId: String(newListId ?? existingCard.listId),
},
{
boardId: card.boardPublicId,
boardName: card.boardName,
listName: card.listName,
user: ctx.user
? { id: ctx.user.id, name: ctx.user.name }
: undefined,
changes:
Object.keys(webhookChanges).length > 0
? webhookChanges
: undefined,
},
),
).catch((error) => {
console.error("Webhook delivery failed:", error);
});
return result;
}),
delete: protectedProcedure
@@ -1137,9 +1054,6 @@ export const cardRouter = createTRPCRouter({
card.createdBy,
);
// Fetch full card data before delete for webhook
const fullCard = await cardRepo.getByPublicId(ctx.db, input.cardPublicId);
const deletedAt = new Date();
await cardRepo.softDelete(ctx.db, {
@@ -1154,34 +1068,6 @@ export const cardRouter = createTRPCRouter({
createdBy: userId,
});
// Fire webhooks (non-blocking)
if (fullCard) {
sendWebhooksForWorkspace(
ctx.db,
card.workspaceId,
createCardWebhookPayload(
"card.deleted",
{
id: String(fullCard.id),
title: fullCard.title,
description: fullCard.description,
dueDate: fullCard.dueDate,
listId: String(fullCard.listId),
},
{
boardId: card.boardPublicId,
boardName: card.boardName,
listName: card.listName,
user: ctx.user
? { id: ctx.user.id, name: ctx.user.name }
: undefined,
},
),
).catch((error) => {
console.error("Webhook delivery failed:", error);
});
}
return { success: true };
}),
});

View File

@@ -81,16 +81,6 @@ export const checklistRouter = createTRPCRouter({
return newChecklist;
}),
update: protectedProcedure
.meta({
openapi: {
summary: "Update a checklist",
method: "PUT",
path: "/checklists/{checklistPublicId}",
description: "Updates a checklist by its public ID",
tags: ["Cards"],
protect: true,
},
})
.input(
z.object({
checklistPublicId: z.string().length(12),

View File

@@ -11,12 +11,10 @@ import * as labelRepo from "@kan/db/repository/label.repo";
import * as listRepo from "@kan/db/repository/list.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { colours } from "@kan/shared/constants";
import { generateSlug, generateUID } from "@kan/shared/utils";
import { generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertPermission } from "../utils/permissions";
import { assertUserInWorkspace } from "../utils/auth";
import { decryptToken } from "../utils/encryption";
import { apiKeys, urls } from "./integration";
export interface TrelloBoard {
@@ -52,51 +50,6 @@ interface TrelloCheckItem {
pos: number;
}
interface GitHubProjectsResponse {
data: {
viewer: {
projectsV2: {
nodes?: { id: string; title: string }[];
};
organizations: {
nodes: {
projectsV2: {
nodes?: { id: string; title: string }[];
};
}[];
};
};
};
errors?: unknown[];
}
interface GitHubGraphQLResponse {
data?: {
node?: GitHubProjectV2Node;
};
errors?: unknown[];
}
interface GitHubProjectV2Node {
title: string;
field?: {
options?: { id: string; name: string }[];
};
areaField?: {
options?: { id: string; name: string; color: string }[];
};
items?: {
nodes: {
fieldValueByName?: { name: string };
areaValue?: { name: string };
content?: {
title?: string;
body?: string;
};
}[];
};
}
interface TrelloCard {
id: string;
name: string | null;
@@ -511,436 +464,4 @@ export const importRouter = createTRPCRouter({
return { boardsCreated };
}),
}),
github: createTRPCRouter({
getProjects: protectedProcedure
.meta({
openapi: {
summary: "Get projects from GitHub",
method: "GET",
path: "/integrations/github/projects",
description: "Retrieves all projects from GitHub",
tags: ["Integrations"],
protect: true,
},
})
.input(z.void())
.output(z.array(z.object({ id: z.string(), name: z.string() })))
.query(async ({ ctx }) => {
const user = ctx.user;
if (!user)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const integration = await integrationsRepo.getProviderForUser(
ctx.db,
user.id,
"github",
);
if (!integration)
throw new TRPCError({
message: "GitHub token not found",
code: "UNAUTHORIZED",
});
const token = decryptToken(integration.accessToken);
// GraphQL query to fetch Projects V2 for the user and their organizations
const query = `
query {
viewer {
projectsV2(first: 20) {
nodes {
id
title
}
}
organizations(first: 10) {
nodes {
projectsV2(first: 10) {
nodes {
id
title
}
}
}
}
}
}
`;
const response = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
Authorization: `token ${token}`,
"Content-Type": "application/json",
"User-Agent": "Kan-App",
},
body: JSON.stringify({ query }),
});
if (!response.ok) {
const errorText = await response.text();
console.error(
`GitHub API Error: ${response.status} ${response.statusText}`,
);
console.error(`GitHub API Response: ${errorText}`);
throw new TRPCError({
message: `Failed to fetch GitHub projects: ${response.status} ${response.statusText}`,
code: "INTERNAL_SERVER_ERROR",
});
}
const result = (await response.json()) as GitHubProjectsResponse;
if (result.errors) {
console.error("GitHub GraphQL Errors:", result.errors);
throw new TRPCError({
message: "Failed to fetch GitHub projects (GraphQL Error)",
code: "INTERNAL_SERVER_ERROR",
});
}
const userProjects = result.data.viewer.projectsV2.nodes ?? [];
const orgProjects = result.data.viewer.organizations.nodes.flatMap(
(org) => org.projectsV2.nodes ?? [],
);
const allProjects = [...userProjects, ...orgProjects];
return allProjects.map((project) => ({
id: project.id,
name: project.title,
}));
}),
importProjects: protectedProcedure
.meta({
openapi: {
summary: "Import projects from GitHub",
method: "POST",
path: "/imports/github/projects",
description: "Imports projects from GitHub",
tags: ["Imports"],
protect: true,
},
})
.input(
z.object({
projectIds: z.array(z.string()),
workspacePublicId: z.string().min(12),
}),
)
.output(z.object({ projectsImported: z.number() }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId) throw new TRPCError({ code: "UNAUTHORIZED" });
const integration = await integrationsRepo.getProviderForUser(
ctx.db,
userId,
"github",
);
if (!integration)
throw new TRPCError({
code: "UNAUTHORIZED",
message: "GitHub token not found",
});
const token = decryptToken(integration.accessToken);
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace)
throw new TRPCError({
code: "NOT_FOUND",
message: "Workspace not found",
});
await assertUserInWorkspace(ctx.db, userId, workspace.id);
const newImport = await importRepo.create(ctx.db, {
source: "github",
createdBy: userId,
});
if (!newImport) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create import record",
});
}
const newImportId = newImport.id;
let projectsImported = 0;
for (const projectId of input.projectIds) {
// GraphQL query to fetch Project V2 details, status options, area options, and items
const query = `
query($id: ID!) {
node(id: $id) {
... on ProjectV2 {
title
field(name: "Status") {
... on ProjectV2SingleSelectField {
options {
id
name
}
}
}
areaField: field(name: "Area") {
... on ProjectV2SingleSelectField {
options {
id
name
color
}
}
}
items(first: 100) {
nodes {
fieldValueByName(name: "Status") {
... on ProjectV2ItemFieldSingleSelectValue {
name
}
}
areaValue: fieldValueByName(name: "Area") {
... on ProjectV2ItemFieldSingleSelectValue {
name
}
}
content {
... on Issue {
title
body
}
... on PullRequest {
title
body
}
... on DraftIssue {
title
body
}
}
}
}
}
}
}
`;
const response = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
Authorization: `token ${token}`,
"Content-Type": "application/json",
"User-Agent": "Kan-App",
},
body: JSON.stringify({ query, variables: { id: projectId } }),
});
const result = (await response.json()) as GitHubGraphQLResponse;
if (result.errors || !result.data?.node) continue;
const projectData = result.data.node;
const statusOptions = projectData.field?.options ?? [];
const areaOptions = projectData.areaField?.options ?? [];
const items = projectData.items?.nodes ?? [];
const boardPublicId = generateUID();
const board = await boardRepo.create(ctx.db, {
publicId: boardPublicId,
name: projectData.title,
workspaceId: workspace.id,
slug: generateSlug(projectData.title),
createdBy: userId,
importId: newImportId,
});
if (!board) continue;
// Prepare Labels
const labelsInsert = areaOptions.map((option) => {
let colourCode = "#0d9488"; // Default Teal
const ghColor = option.color;
// Map GitHub colors to Kan colors
if (ghColor === "BLUE") colourCode = "#0284c7";
else if (ghColor === "GREEN") colourCode = "#65a30d";
else if (ghColor === "YELLOW") colourCode = "#ca8a04";
else if (ghColor === "ORANGE") colourCode = "#ea580c";
else if (ghColor === "RED") colourCode = "#dc2626";
else if (ghColor === "PINK") colourCode = "#db2777";
else if (ghColor === "PURPLE") colourCode = "#4f46e5";
else if (ghColor === "GRAY") colourCode = "#0d9488";
return {
publicId: generateUID(),
name: option.name,
colourCode,
createdBy: userId,
boardId: board.id,
importId: newImportId,
};
});
const createdLabels = await labelRepo.bulkCreate(
ctx.db,
labelsInsert,
);
const labelMap = new Map<string, number>();
createdLabels.forEach((label, index) => {
const originalName = areaOptions[index]?.name;
if (originalName) {
labelMap.set(originalName, label.id);
}
});
// Prepare Lists
const listsInsert: {
publicId: string;
name: string;
createdBy: string;
boardId: number;
index: number;
importId: number;
}[] = [];
if (statusOptions.length === 0) {
listsInsert.push({
publicId: generateUID(),
name: "To Do",
createdBy: userId,
boardId: board.id,
index: 0,
importId: newImportId,
});
} else {
statusOptions.forEach((option, index) => {
listsInsert.push({
publicId: generateUID(),
name: option.name,
createdBy: userId,
boardId: board.id,
index: index,
importId: newImportId,
});
});
}
const createdLists = await listRepo.bulkCreate(ctx.db, listsInsert);
const listIdMap = new Map<string, number>();
createdLists.forEach((list, index) => {
const originalName = listsInsert[index]?.name;
if (originalName) {
listIdMap.set(originalName, list.id);
}
});
// Prepare Cards
const itemsToInsert: {
item: NonNullable<
NonNullable<
NonNullable<GitHubProjectV2Node["items"]>["nodes"]
>[number]
>;
listId: number;
title: string;
description: string;
}[] = [];
for (const item of items) {
const statusName = item.fieldValueByName?.name;
const content = item.content ?? {};
const title = content.title ?? "Untitled Card";
const description = content.body ?? "";
let listId = statusName ? listIdMap.get(statusName) : undefined;
// Fallback to first list
if (!listId && createdLists.length > 0) {
listId = createdLists[0]?.id;
}
if (listId) {
itemsToInsert.push({
item,
listId,
title,
description,
});
}
}
const cardsInput = itemsToInsert.map((data, index) => ({
publicId: generateUID(),
title: data.title,
description: data.description,
createdBy: userId,
listId: data.listId,
index: index,
importId: newImportId,
}));
const createdCards = await cardRepo.bulkCreate(ctx.db, cardsInput);
// Create Activities
const activities = createdCards.map((card) => ({
type: "card.created" as const,
cardId: card.id,
createdBy: userId,
}));
if (activities.length > 0) {
await cardActivityRepo.bulkCreate(ctx.db, activities);
}
// Link Labels
const cardLabelRelations: { cardId: number; labelId: number }[] = [];
createdCards.forEach((card, index) => {
const originalItem = itemsToInsert[index]?.item;
const areaName = originalItem?.areaValue?.name;
if (areaName) {
const labelId = labelMap.get(areaName);
if (labelId) {
cardLabelRelations.push({
cardId: card.id,
labelId: labelId,
});
}
}
});
if (cardLabelRelations.length > 0) {
await cardRepo.bulkCreateCardLabelRelationships(
ctx.db,
cardLabelRelations,
);
}
projectsImported++;
}
if (projectsImported > 0 && newImportId) {
await importRepo.update(
ctx.db,
{ status: "success" },
{ importId: newImportId },
);
}
return { projectsImported };
}),
}),
});

View File

@@ -14,65 +14,7 @@ export const apiKeys = {
trello: process.env.TRELLO_APP_API_KEY,
};
import { encryptToken } from "../utils/encryption";
export const integrationRouter = createTRPCRouter({
saveGitHubToken: protectedProcedure
.input(z.object({ token: z.string() }))
.mutation(async ({ ctx, input }) => {
const user = ctx.user;
if (!user)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const encryptedToken = encryptToken(input.token);
const expiresAt = new Date();
expiresAt.setFullYear(expiresAt.getFullYear() + 1);
await integrationsRepo.createOrUpdateProvider(ctx.db, {
provider: "github",
userId: user.id,
accessToken: encryptedToken,
expiresAt,
});
return { success: true };
}),
disconnectGitHub: protectedProcedure.mutation(async ({ ctx }) => {
const user = ctx.user;
if (!user)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
await integrationsRepo.deleteProviderForUser(ctx.db, user.id, "github");
return { success: true };
}),
getGitHubStatus: protectedProcedure.query(async ({ ctx }) => {
const user = ctx.user;
if (!user)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const connected = await integrationsRepo.isProviderAvailableForUser(
ctx.db,
user.id,
"github",
);
return { connected };
}),
providers: protectedProcedure
.meta({
openapi: {
@@ -125,7 +67,7 @@ export const integrationRouter = createTRPCRouter({
protect: true,
},
})
.input(z.object({ provider: z.enum(["trello", "github"]) }))
.input(z.object({ provider: z.enum(["trello"]) }))
.output(z.object({}))
.mutation(async ({ ctx, input }) => {
const user = ctx.user;

View File

@@ -210,12 +210,11 @@ export const memberRouter = createTRPCRouter({
input.memberPublicId,
);
if (!member || member.workspaceId !== workspace.id) {
if (!member)
throw new TRPCError({
message: `Member with public ID ${input.memberPublicId} not found`,
code: "NOT_FOUND",
});
}
const deletedMember = await memberRepo.softDelete(ctx.db, {
memberId: member.id,
@@ -721,7 +720,7 @@ export const memberRouter = createTRPCRouter({
input.memberPublicId,
);
if (!member || member.workspaceId !== workspace.id) {
if (!member) {
throw new TRPCError({
message: "Member not found",
code: "NOT_FOUND",

View File

@@ -133,7 +133,7 @@ export const permissionRouter = createTRPCRouter({
input.memberPublicId,
);
if (!member || member.workspaceId !== workspace.id) {
if (!member) {
throw new TRPCError({
message: "Member not found",
code: "NOT_FOUND",
@@ -209,7 +209,7 @@ export const permissionRouter = createTRPCRouter({
input.memberPublicId,
);
if (!member || member.workspaceId !== workspace.id) {
if (!member) {
throw new TRPCError({
message: "Member not found",
code: "NOT_FOUND",
@@ -274,7 +274,7 @@ export const permissionRouter = createTRPCRouter({
input.memberPublicId,
);
if (!member || member.workspaceId !== workspace.id) {
if (!member) {
throw new TRPCError({
message: "Member not found",
code: "NOT_FOUND",
@@ -339,7 +339,7 @@ export const permissionRouter = createTRPCRouter({
input.memberPublicId,
);
if (!member || member.workspaceId !== workspace.id) {
if (!member) {
throw new TRPCError({
message: "Member not found",
code: "NOT_FOUND",

View File

@@ -1,423 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TRPCError } from "@trpc/server";
vi.mock("@kan/db/repository/webhook.repo", () => ({
getAllByWorkspaceId: vi.fn(),
getByPublicId: vi.fn(),
create: vi.fn(),
update: vi.fn(),
hardDelete: vi.fn(),
}));
vi.mock("@kan/db/repository/workspace.repo", () => ({
getByPublicId: vi.fn(),
}));
vi.mock("../utils/permissions", () => ({
assertPermission: vi.fn(),
}));
import * as webhookRepo from "@kan/db/repository/webhook.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { assertPermission } from "../utils/permissions";
const mockGetAllByWorkspaceId = webhookRepo.getAllByWorkspaceId as ReturnType<typeof vi.fn>;
const mockGetByPublicId = webhookRepo.getByPublicId as ReturnType<typeof vi.fn>;
const mockCreate = webhookRepo.create as ReturnType<typeof vi.fn>;
const mockUpdate = webhookRepo.update as ReturnType<typeof vi.fn>;
const mockHardDelete = webhookRepo.hardDelete as ReturnType<typeof vi.fn>;
const mockWorkspaceGetByPublicId = workspaceRepo.getByPublicId as ReturnType<typeof vi.fn>;
const mockAssertPermission = assertPermission as ReturnType<typeof vi.fn>;
// We need to import the router after mocks are set up
// Testing approach: call the internal handler logic through a test wrapper
describe("webhook router", () => {
const mockDb = {} as never;
const mockUser = { id: "user-123", name: "Test User", email: "test@example.com" };
const mockWorkspace = { id: 1, publicId: "ws-123456789" };
const mockWebhook = {
id: 1,
publicId: "wh-123456789",
workspaceId: 1,
name: "My Webhook",
url: "https://example.com/webhook",
secret: "secret123",
events: ["card.created", "card.updated"] as const,
active: true,
createdAt: new Date("2024-01-15"),
updatedAt: null,
};
beforeEach(() => {
vi.clearAllMocks();
mockAssertPermission.mockResolvedValue(undefined);
});
describe("authorization", () => {
it("throws UNAUTHORIZED when user is not authenticated", async () => {
// Import fresh to get mocked version
const { webhookRouter } = await import("./webhook");
const ctx = {
user: null,
db: mockDb,
} as never;
await expect(
webhookRouter.createCaller(ctx).list({ workspacePublicId: "ws-123456789" }),
).rejects.toThrow(TRPCError);
});
it("throws NOT_FOUND when workspace does not exist", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(null);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
await expect(
webhookRouter.createCaller(ctx).list({ workspacePublicId: "ws-nonexistent" }),
).rejects.toThrow(TRPCError);
});
it("checks workspace:manage permission via assertPermission", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetAllByWorkspaceId.mockResolvedValueOnce([]);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
await webhookRouter.createCaller(ctx).list({ workspacePublicId: "ws-123456789" });
expect(mockAssertPermission).toHaveBeenCalledWith(
mockDb,
mockUser.id,
mockWorkspace.id,
"workspace:manage",
);
});
});
describe("list", () => {
it("returns all webhooks for workspace", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetAllByWorkspaceId.mockResolvedValueOnce([mockWebhook]);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
const result = await webhookRouter.createCaller(ctx).list({
workspacePublicId: "ws-123456789",
});
expect(result).toHaveLength(1);
expect(result[0]!.name).toBe("My Webhook");
expect(mockGetAllByWorkspaceId).toHaveBeenCalledWith(mockDb, mockWorkspace.id);
});
it("returns empty array when no webhooks exist", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetAllByWorkspaceId.mockResolvedValueOnce([]);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
const result = await webhookRouter.createCaller(ctx).list({
workspacePublicId: "ws-123456789",
});
expect(result).toEqual([]);
});
});
describe("create", () => {
it("creates a webhook with valid input", async () => {
const { webhookRouter } = await import("./webhook");
const newWebhook = {
publicId: "wh-new123456",
name: "New Webhook",
url: "https://example.com/new",
events: ["card.created"] as const,
active: true,
createdAt: new Date(),
};
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockCreate.mockResolvedValueOnce(newWebhook);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
const result = await webhookRouter.createCaller(ctx).create({
workspacePublicId: "ws-123456789",
name: "New Webhook",
url: "https://example.com/new",
events: ["card.created"],
});
expect(result.name).toBe("New Webhook");
expect(mockCreate).toHaveBeenCalledWith(mockDb, {
workspaceId: mockWorkspace.id,
name: "New Webhook",
url: "https://example.com/new",
secret: undefined,
events: ["card.created"],
createdBy: mockUser.id,
});
});
it("creates a webhook with secret", async () => {
const { webhookRouter } = await import("./webhook");
const newWebhook = {
publicId: "wh-new123456",
name: "Secure Webhook",
url: "https://example.com/secure",
events: ["card.created"] as const,
active: true,
createdAt: new Date(),
};
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockCreate.mockResolvedValueOnce(newWebhook);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
await webhookRouter.createCaller(ctx).create({
workspacePublicId: "ws-123456789",
name: "Secure Webhook",
url: "https://example.com/secure",
secret: "my-secret-key",
events: ["card.created"],
});
expect(mockCreate).toHaveBeenCalledWith(mockDb, expect.objectContaining({
secret: "my-secret-key",
}));
});
it("throws INTERNAL_SERVER_ERROR when create fails", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockCreate.mockResolvedValueOnce(null);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
await expect(
webhookRouter.createCaller(ctx).create({
workspacePublicId: "ws-123456789",
name: "New Webhook",
url: "https://example.com/new",
events: ["card.created"],
}),
).rejects.toThrow(TRPCError);
});
});
describe("update", () => {
it("updates webhook name", async () => {
const { webhookRouter } = await import("./webhook");
const updatedWebhook = { ...mockWebhook, name: "Updated Name" };
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetByPublicId.mockResolvedValueOnce(mockWebhook);
mockUpdate.mockResolvedValueOnce(updatedWebhook);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
const result = await webhookRouter.createCaller(ctx).update({
workspacePublicId: "ws-123456789",
webhookPublicId: "wh-123456789",
name: "Updated Name",
});
expect(result.name).toBe("Updated Name");
expect(mockUpdate).toHaveBeenCalledWith(mockDb, "wh-123456789", {
name: "Updated Name",
url: undefined,
secret: undefined,
events: undefined,
active: undefined,
});
});
it("throws NOT_FOUND when webhook does not exist", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetByPublicId.mockResolvedValueOnce(null);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
await expect(
webhookRouter.createCaller(ctx).update({
workspacePublicId: "ws-123456789",
webhookPublicId: "wh-nonexistent",
name: "Updated Name",
}),
).rejects.toThrow(TRPCError);
});
it("throws NOT_FOUND when webhook belongs to different workspace", async () => {
const { webhookRouter } = await import("./webhook");
const webhookFromDifferentWorkspace = { ...mockWebhook, workspaceId: 999 };
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetByPublicId.mockResolvedValueOnce(webhookFromDifferentWorkspace);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
await expect(
webhookRouter.createCaller(ctx).update({
workspacePublicId: "ws-123456789",
webhookPublicId: "wh-123456789",
name: "Updated Name",
}),
).rejects.toThrow(TRPCError);
});
});
describe("delete", () => {
it("deletes webhook successfully", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetByPublicId.mockResolvedValueOnce(mockWebhook);
mockHardDelete.mockResolvedValueOnce(undefined);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
const result = await webhookRouter.createCaller(ctx).delete({
workspacePublicId: "ws-123456789",
webhookPublicId: "wh-123456789",
});
expect(result).toEqual({ success: true });
expect(mockHardDelete).toHaveBeenCalledWith(mockDb, "wh-123456789");
});
it("throws NOT_FOUND when webhook does not exist", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetByPublicId.mockResolvedValueOnce(null);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
await expect(
webhookRouter.createCaller(ctx).delete({
workspacePublicId: "ws-123456789",
webhookPublicId: "wh-nonexistent",
}),
).rejects.toThrow(TRPCError);
});
});
describe("test", () => {
beforeEach(() => {
global.fetch = vi.fn();
});
it("sends test payload to webhook URL", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetByPublicId.mockResolvedValueOnce(mockWebhook);
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
});
const ctx = {
user: mockUser,
db: mockDb,
} as never;
const result = await webhookRouter.createCaller(ctx).test({
workspacePublicId: "ws-123456789",
webhookPublicId: "wh-123456789",
});
expect(result.success).toBe(true);
expect(result.statusCode).toBe(200);
expect(global.fetch).toHaveBeenCalledWith(
mockWebhook.url,
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
"Content-Type": "application/json",
"X-Webhook-Event": "card.created",
}),
}),
);
});
it("returns error when test fails", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetByPublicId.mockResolvedValueOnce(mockWebhook);
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "Internal Server Error",
});
const ctx = {
user: mockUser,
db: mockDb,
} as never;
const result = await webhookRouter.createCaller(ctx).test({
workspacePublicId: "ws-123456789",
webhookPublicId: "wh-123456789",
});
expect(result.success).toBe(false);
expect(result.statusCode).toBe(500);
expect(result.error).toContain("500");
});
});
});

View File

@@ -1,359 +0,0 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import * as webhookRepo from "@kan/db/repository/webhook.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { webhookEvents } from "@kan/db/schema";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertPermission } from "../utils/permissions";
import {
webhookUrlSchema,
sendWebhookToUrl,
createCardWebhookPayload,
} from "../utils/webhook";
const webhookEventSchema = z.enum(webhookEvents);
export const webhookRouter = createTRPCRouter({
list: protectedProcedure
.meta({
openapi: {
summary: "Get all webhooks for a workspace",
method: "GET",
path: "/workspaces/{workspacePublicId}/webhooks",
description: "Retrieves all webhooks configured for a workspace",
tags: ["Webhooks"],
protect: true,
},
})
.input(z.object({ workspacePublicId: z.string().min(12) }))
.output(
z.array(
z.object({
publicId: z.string(),
name: z.string(),
url: z.string(),
events: z.array(webhookEventSchema),
active: z.boolean(),
createdAt: z.date(),
updatedAt: z.date().nullable(),
}),
),
)
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace)
throw new TRPCError({
message: "Workspace not found",
code: "NOT_FOUND",
});
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
return webhookRepo.getAllByWorkspaceId(ctx.db, workspace.id);
}),
create: protectedProcedure
.meta({
openapi: {
summary: "Create a webhook",
method: "POST",
path: "/workspaces/{workspacePublicId}/webhooks",
description: "Creates a new webhook for a workspace",
tags: ["Webhooks"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
name: z.string().min(1).max(255),
url: webhookUrlSchema,
secret: z.string().max(512).optional(),
events: z.array(webhookEventSchema).min(1),
}),
)
.output(
z.object({
publicId: z.string(),
name: z.string(),
url: z.string(),
events: z.array(webhookEventSchema),
active: z.boolean(),
createdAt: z.date(),
}),
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace)
throw new TRPCError({
message: "Workspace not found",
code: "NOT_FOUND",
});
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
const result = await webhookRepo.create(ctx.db, {
workspaceId: workspace.id,
name: input.name,
url: input.url,
secret: input.secret,
events: input.events,
createdBy: userId,
});
if (!result)
throw new TRPCError({
message: "Unable to create webhook",
code: "INTERNAL_SERVER_ERROR",
});
return result;
}),
update: protectedProcedure
.meta({
openapi: {
summary: "Update a webhook",
method: "PUT",
path: "/workspaces/{workspacePublicId}/webhooks/{webhookPublicId}",
description: "Updates a webhook by its public ID",
tags: ["Webhooks"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
webhookPublicId: z.string().min(12),
name: z.string().min(1).max(255).optional(),
url: webhookUrlSchema.optional(),
secret: z.string().max(512).optional(),
events: z.array(webhookEventSchema).min(1).optional(),
active: z.boolean().optional(),
}),
)
.output(
z.object({
publicId: z.string(),
name: z.string(),
url: z.string(),
events: z.array(webhookEventSchema),
active: z.boolean(),
createdAt: z.date(),
updatedAt: z.date().nullable(),
}),
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace)
throw new TRPCError({
message: "Workspace not found",
code: "NOT_FOUND",
});
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
const webhook = await webhookRepo.getByPublicId(
ctx.db,
input.webhookPublicId,
);
if (!webhook || webhook.workspaceId !== workspace.id)
throw new TRPCError({
message: "Webhook not found",
code: "NOT_FOUND",
});
const result = await webhookRepo.update(ctx.db, input.webhookPublicId, {
name: input.name,
url: input.url,
secret: input.secret,
events: input.events,
active: input.active,
});
if (!result)
throw new TRPCError({
message: "Unable to update webhook",
code: "INTERNAL_SERVER_ERROR",
});
return result;
}),
delete: protectedProcedure
.meta({
openapi: {
summary: "Delete a webhook",
method: "DELETE",
path: "/workspaces/{workspacePublicId}/webhooks/{webhookPublicId}",
description: "Deletes a webhook by its public ID",
tags: ["Webhooks"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
webhookPublicId: z.string().min(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 workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace)
throw new TRPCError({
message: "Workspace not found",
code: "NOT_FOUND",
});
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
const webhook = await webhookRepo.getByPublicId(
ctx.db,
input.webhookPublicId,
);
if (!webhook || webhook.workspaceId !== workspace.id)
throw new TRPCError({
message: "Webhook not found",
code: "NOT_FOUND",
});
await webhookRepo.hardDelete(ctx.db, input.webhookPublicId);
return { success: true };
}),
test: protectedProcedure
.meta({
openapi: {
summary: "Test a webhook",
method: "POST",
path: "/workspaces/{workspacePublicId}/webhooks/{webhookPublicId}/test",
description: "Sends a test payload to a webhook",
tags: ["Webhooks"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
webhookPublicId: z.string().min(12),
}),
)
.output(
z.object({
success: z.boolean(),
statusCode: z.number().optional(),
error: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace)
throw new TRPCError({
message: "Workspace not found",
code: "NOT_FOUND",
});
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
const webhook = await webhookRepo.getByPublicId(
ctx.db,
input.webhookPublicId,
);
if (!webhook || webhook.workspaceId !== workspace.id)
throw new TRPCError({
message: "Webhook not found",
code: "NOT_FOUND",
});
const testPayload = createCardWebhookPayload("card.created", {
id: "test-card-id",
title: "Test Card",
description: "This is a test webhook payload",
dueDate: null,
listId: "test-list-id",
}, {
boardId: "test-board-id",
boardName: "Test Board",
listName: "Test List",
user: {
id: userId,
name: ctx.user?.name ?? "Test User",
},
});
const result = await sendWebhookToUrl(
webhook.url,
webhook.secret ?? undefined,
testPayload,
);
return result;
}),
});

View File

@@ -4,10 +4,11 @@ import { z } from "zod";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import * as workspaceSlugRepo from "@kan/db/repository/workspaceSlug.repo";
import { generateAvatarUrl, generateUID } from "@kan/shared/utils";
import { generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { assertPermission } from "../utils/permissions";
import { generateAvatarUrl } from "@kan/shared/utils";
export const workspaceRouter = createTRPCRouter({
all: protectedProcedure
@@ -83,7 +84,8 @@ export const workspaceRouter = createTRPCRouter({
const isAdmin = userMember?.role === "admin";
// Show emails if user is admin OR workspace setting allows it
const shouldShowEmails = isAdmin || result.showEmailsToMembers === true;
const shouldShowEmails =
isAdmin || result.showEmailsToMembers === true;
// Generate presigned URLs for member avatars
const membersWithAvatarUrls = await Promise.all(
@@ -158,7 +160,7 @@ export const workspaceRouter = createTRPCRouter({
workspaceSlug: z
.string()
.min(3)
.max(64)
.max(24)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
}),
)
@@ -205,7 +207,7 @@ export const workspaceRouter = createTRPCRouter({
slug: z
.string()
.min(3)
.max(64)
.max(24)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
.optional(),
}),
@@ -288,14 +290,11 @@ export const workspaceRouter = createTRPCRouter({
slug: z
.string()
.min(3)
.max(64)
.max(24)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
.optional(),
description: z.string().min(3).max(280).optional(),
showEmailsToMembers: z.boolean().optional(),
weekStartDay: z
.union([z.literal(0), z.literal(1), z.literal(6)])
.optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.update>>>())
@@ -357,7 +356,6 @@ export const workspaceRouter = createTRPCRouter({
slug: input.slug,
description: input.description,
showEmailsToMembers: input.showEmailsToMembers,
weekStartDay: input.weekStartDay,
},
);
@@ -426,7 +424,7 @@ export const workspaceRouter = createTRPCRouter({
workspaceSlug: z
.string()
.min(3)
.max(64)
.max(24)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
}),
)

Some files were not shown because too many files have changed in this diff Show More