Compare commits
4 Commits
feat/agent
...
feat/handl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f975633baf | ||
|
|
8a02613d84 | ||
|
|
885f119404 | ||
|
|
b17a24455a |
@@ -15,4 +15,4 @@ pnpm-debug.log
|
|||||||
|
|
||||||
README.md
|
README.md
|
||||||
.next
|
.next
|
||||||
.git
|
# .git
|
||||||
315
AGENTS.md
315
AGENTS.md
@@ -1,315 +0,0 @@
|
|||||||
# AGENTS.md
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
Kan is an open-source project management tool (Trello alternative) built with:
|
|
||||||
|
|
||||||
- **Frontend**: Next.js, React, TypeScript, Tailwind CSS
|
|
||||||
- **Backend**: tRPC, Node.js
|
|
||||||
- **Database**: PostgreSQL with Drizzle ORM
|
|
||||||
- **Monorepo**: pnpm workspaces with Turbo
|
|
||||||
- **Auth**: Better Auth
|
|
||||||
- **Internationalization**: Lingui
|
|
||||||
|
|
||||||
## Setup Commands
|
|
||||||
|
|
||||||
- Install deps: `pnpm install`
|
|
||||||
- Start dev server: `pnpm dev`
|
|
||||||
- Create migrations: `cd packages/db && pnpm drizzle-kit generate --name "AddFieldToTable"`
|
|
||||||
- Run database migrations: `pnpm db:migrate`
|
|
||||||
- Run linter: `pnpm lint`
|
|
||||||
- Run type check: `pnpm typecheck`
|
|
||||||
- Format code: `pnpm format:fix`
|
|
||||||
- Extract i18n strings: `pnpm lingui:extract`
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
- `apps/web/` - Next.js web application
|
|
||||||
- `packages/api/` - tRPC API routers
|
|
||||||
- `packages/db/` - Database schema, migrations, and repositories
|
|
||||||
- `packages/auth/` - Authentication package
|
|
||||||
- `packages/shared/` - Shared utilities
|
|
||||||
- `packages/email/` - Email templates and sending
|
|
||||||
- `packages/stripe/` - Stripe integration
|
|
||||||
- `tooling/` - Shared tooling configs (ESLint, Prettier, TypeScript)
|
|
||||||
|
|
||||||
## Code Style
|
|
||||||
|
|
||||||
### TypeScript
|
|
||||||
|
|
||||||
- Use TypeScript strictly - avoid `any` types
|
|
||||||
- Prefer explicit types over inference when it improves clarity
|
|
||||||
- Use `as const` for literal types when appropriate
|
|
||||||
- Follow existing patterns for type definitions
|
|
||||||
|
|
||||||
### Naming Conventions
|
|
||||||
|
|
||||||
- **Files**: kebab-case for files (e.g., `card-repo.ts`)
|
|
||||||
- **Components**: PascalCase for React components
|
|
||||||
- **Functions**: camelCase for functions
|
|
||||||
- **Constants**: UPPER_SNAKE_CASE for constants
|
|
||||||
- **Types/Interfaces**: PascalCase
|
|
||||||
|
|
||||||
### Database Layer (`packages/db/`)
|
|
||||||
|
|
||||||
- **Schema**: Define schemas in `src/schema/` using Drizzle ORM
|
|
||||||
- **Migrations**: Create migrations with `cd packages/db && pnpm drizzle-kit generate --name "MigrationName"`, then run with `pnpm db:migrate`
|
|
||||||
- **Repositories**: Put database queries in `src/repository/` files
|
|
||||||
- **Soft Deletes**: Use `deletedAt` timestamp for soft deletion (not hard deletes)
|
|
||||||
- **Index Management**: Cards have `index` fields that must be maintained sequentially per list
|
|
||||||
- **Activity Logging**: Use `card_activity` table to track all card changes
|
|
||||||
|
|
||||||
### API Layer (`packages/api/`)
|
|
||||||
|
|
||||||
- **Routers**: Create tRPC routers in `src/routers/`
|
|
||||||
- **Procedures**: Use `protectedProcedure` for authenticated endpoints, `publicProcedure` for public
|
|
||||||
- **Validation**: Use Zod schemas for input validation
|
|
||||||
- **Error Handling**: Use `TRPCError` with appropriate error codes
|
|
||||||
- **OpenAPI**: Add OpenAPI metadata for all endpoints
|
|
||||||
- **Authorization**: Always check workspace membership with `assertUserInWorkspace`
|
|
||||||
|
|
||||||
### Frontend (`apps/web/`)
|
|
||||||
|
|
||||||
- **Components**: React components in `src/components/`
|
|
||||||
- **Views**: Page-level components in `src/views/`
|
|
||||||
- **Hooks**: Custom hooks in `src/hooks/`
|
|
||||||
- **i18n**: Use `t` template literal for translations (Lingui)
|
|
||||||
- **Styling**: Use Tailwind CSS classes
|
|
||||||
- **State Management**: Use tRPC React Query hooks for server state
|
|
||||||
- **Modals**: Use `useModal` hook for modal management
|
|
||||||
- **Popups**: Use `usePopup` hook for toast notifications
|
|
||||||
|
|
||||||
## Key Concepts
|
|
||||||
|
|
||||||
### Cards
|
|
||||||
|
|
||||||
- Cards are the main entity in Kan
|
|
||||||
- Cards belong to Lists, which belong to Boards
|
|
||||||
- Cards have: title, description, labels, members, checklists, comments, attachments, due dates
|
|
||||||
- Cards use soft deletion (`deletedAt` field)
|
|
||||||
- Cards have an `index` field that must be maintained sequentially per list
|
|
||||||
- All card changes are tracked in `card_activity` table
|
|
||||||
|
|
||||||
### Activity Tracking
|
|
||||||
|
|
||||||
- Every significant card change creates an activity record
|
|
||||||
- Activity types include: created, updated (various fields), etc.
|
|
||||||
- Activities are displayed in card activity feeds
|
|
||||||
|
|
||||||
### Workspaces & Boards
|
|
||||||
|
|
||||||
- Users belong to Workspaces
|
|
||||||
- Boards belong to Workspaces
|
|
||||||
- Workspace members have different permission levels
|
|
||||||
- Boards can be public or private
|
|
||||||
|
|
||||||
### Soft Deletion Pattern
|
|
||||||
|
|
||||||
- Entities use `deletedAt` timestamp for soft deletion
|
|
||||||
- Queries filter with `isNull(table.deletedAt)` to exclude deleted items
|
|
||||||
|
|
||||||
## File Locations Reference
|
|
||||||
|
|
||||||
### Database
|
|
||||||
|
|
||||||
- Schema: `packages/db/src/schema/*.ts`
|
|
||||||
- Repositories: `packages/db/src/repository/*.repo.ts`
|
|
||||||
- Migrations: `packages/db/migrations/`
|
|
||||||
|
|
||||||
### API
|
|
||||||
|
|
||||||
- Routers: `packages/api/src/routers/*.ts`
|
|
||||||
- Utils: `packages/api/src/utils/`
|
|
||||||
- Types: `packages/api/src/types/`
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
- Components: `apps/web/src/components/`
|
|
||||||
- Views: `apps/web/src/views/`
|
|
||||||
- Pages: `apps/web/src/pages/`
|
|
||||||
- Hooks: `apps/web/src/hooks/`
|
|
||||||
- Utils: `apps/web/src/utils/`
|
|
||||||
- Locales: `apps/web/src/locales/`
|
|
||||||
|
|
||||||
## Database Patterns
|
|
||||||
|
|
||||||
- **Soft Deletes**: Always filter with `isNull(table.deletedAt)` in queries
|
|
||||||
- **Public IDs**: Use 12-character public IDs (`publicId`) for all user-facing entities
|
|
||||||
- **Internal IDs**: Never expose internal database IDs (e.g., `id`, `cardId`, `listId`) in API responses or URLs - always use `publicId` externally
|
|
||||||
- **Transactions**: Use database transactions for multi-step operations
|
|
||||||
- **Index Management**: When deleting/moving cards, maintain sequential indices
|
|
||||||
- **Activity Tracking**: Create activity records for all significant changes
|
|
||||||
|
|
||||||
## API Patterns
|
|
||||||
|
|
||||||
- **Input Validation**: Always validate inputs with Zod
|
|
||||||
- **Error Messages**: Provide clear, user-friendly error messages
|
|
||||||
- **Optimistic Updates**: Use tRPC's `onMutate` for optimistic UI updates
|
|
||||||
- **Cache Invalidation**: Properly invalidate queries after mutations
|
|
||||||
- **ID Exposure**: Never expose internal database IDs (`id`, `cardId`, `listId`, etc.) in API responses, URLs, or frontend code - always use `publicId` for external communication
|
|
||||||
|
|
||||||
## Important Patterns
|
|
||||||
|
|
||||||
### Card Index Management
|
|
||||||
|
|
||||||
When cards are created, moved, or deleted, their indices must be maintained:
|
|
||||||
|
|
||||||
- New cards: Append to end (max index + 1) or insert at position
|
|
||||||
- Moving cards: Adjust indices of affected cards
|
|
||||||
- Deleting cards: Decrement indices of cards after deleted one
|
|
||||||
- Always use transactions for index updates
|
|
||||||
|
|
||||||
### Activity Logging
|
|
||||||
|
|
||||||
Create activity records for:
|
|
||||||
|
|
||||||
- Card creation
|
|
||||||
- Card updates (title, description, list, etc.)
|
|
||||||
- Label/member additions/removals
|
|
||||||
- Comments
|
|
||||||
- Checklists and items
|
|
||||||
- Attachments
|
|
||||||
- Due dates
|
|
||||||
|
|
||||||
### Authorization
|
|
||||||
|
|
||||||
Always check:
|
|
||||||
|
|
||||||
1. User is authenticated
|
|
||||||
2. User has access to workspace
|
|
||||||
3. User has permission for the operation
|
|
||||||
|
|
||||||
Use `assertUserInWorkspace` helper for workspace checks.
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
- Use TRPCError with appropriate codes (UNAUTHORIZED, NOT_FOUND, etc.)
|
|
||||||
- Provide user-friendly error messages
|
|
||||||
- Log errors appropriately
|
|
||||||
- Show popup notifications for user-facing errors
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
|
|
||||||
### Creating a Card
|
|
||||||
|
|
||||||
1. Create card in repository with proper index management
|
|
||||||
2. Create `card.created` activity
|
|
||||||
3. Handle label/member relationships if provided
|
|
||||||
4. Return the created card
|
|
||||||
|
|
||||||
### Updating a Card
|
|
||||||
|
|
||||||
1. Validate user has workspace access
|
|
||||||
2. Update card fields
|
|
||||||
3. Create appropriate activity records
|
|
||||||
4. Invalidate relevant queries
|
|
||||||
|
|
||||||
### Querying Cards
|
|
||||||
|
|
||||||
- Always filter by `isNull(cards.deletedAt)` in queries
|
|
||||||
- Include related data (labels, members, checklists) via Drizzle relations
|
|
||||||
- Order by `index` for proper card ordering
|
|
||||||
|
|
||||||
## Adding a New Feature
|
|
||||||
|
|
||||||
1. **Database**: Update schema in `packages/db/src/schema/`
|
|
||||||
2. **Migration**: Create migration with `cd packages/db && pnpm drizzle-kit generate --name "MigrationName"`, then run with `pnpm db:migrate`
|
|
||||||
3. **Repository**: Add repository functions in `packages/db/src/repository/`
|
|
||||||
4. **API**: Add tRPC router procedures in `packages/api/src/routers/`
|
|
||||||
5. **Frontend**: Add UI components in `apps/web/src/`
|
|
||||||
6. **i18n**: Add translations for new strings
|
|
||||||
|
|
||||||
## Database Changes
|
|
||||||
|
|
||||||
- Always create migrations (never modify existing migrations)
|
|
||||||
- Create migrations with: `cd packages/db && pnpm drizzle-kit generate --name "MigrationName"`
|
|
||||||
- Run migrations with: `pnpm db:migrate`
|
|
||||||
- Update schema files in `packages/db/src/schema/`
|
|
||||||
- Test migrations on development database first
|
|
||||||
- Update TypeScript types after schema changes
|
|
||||||
- Consider index management for card operations
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
- Use tRPC procedures (not REST)
|
|
||||||
- Add OpenAPI metadata for documentation
|
|
||||||
- Validate inputs with Zod
|
|
||||||
- Check workspace permissions
|
|
||||||
- Create activity records for significant changes
|
|
||||||
|
|
||||||
## Frontend Components
|
|
||||||
|
|
||||||
- Use Tailwind for styling
|
|
||||||
- Follow existing component patterns
|
|
||||||
- Use tRPC hooks for data fetching
|
|
||||||
- Implement optimistic updates where appropriate
|
|
||||||
- Add proper loading and error states
|
|
||||||
|
|
||||||
## Testing Instructions
|
|
||||||
|
|
||||||
- Test database operations in transactions that rollback
|
|
||||||
- Test authorization checks
|
|
||||||
- Test index management when moving/deleting cards
|
|
||||||
- Test activity logging
|
|
||||||
- Test UI interactions
|
|
||||||
- Run `pnpm lint` and `pnpm typecheck` before committing
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
- Use database indexes appropriately
|
|
||||||
- Batch operations when possible
|
|
||||||
- Avoid N+1 queries
|
|
||||||
- Use transactions for related operations
|
|
||||||
- Implement optimistic updates in UI
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
- Always check workspace membership before operations
|
|
||||||
- Validate all inputs
|
|
||||||
- Never expose internal database IDs (`id`, `cardId`, `listId`, etc.) in API responses, URLs, or frontend code - always use `publicId` externally
|
|
||||||
- Sanitize user input
|
|
||||||
|
|
||||||
## Internationalization
|
|
||||||
|
|
||||||
- All user-facing strings must use `t` template literal
|
|
||||||
- Add translations to locale files in `apps/web/src/locales/`
|
|
||||||
- Run `pnpm lingui:extract` to update translation files
|
|
||||||
- Update locale files for all languages
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- Use workspace dependencies (`workspace:*`) for internal packages
|
|
||||||
- Keep dependencies up to date
|
|
||||||
- Use catalog for shared dependency versions
|
|
||||||
|
|
||||||
## When Implementing Features
|
|
||||||
|
|
||||||
### Adding New Card Fields
|
|
||||||
|
|
||||||
1. Update schema in `packages/db/src/schema/cards.ts`
|
|
||||||
2. Create migration
|
|
||||||
3. Update repository functions
|
|
||||||
4. Add API endpoints
|
|
||||||
5. Update frontend components
|
|
||||||
6. Add activity tracking if needed
|
|
||||||
|
|
||||||
### Adding New Activity Types
|
|
||||||
|
|
||||||
1. Add to `activityTypes` array in schema
|
|
||||||
2. Create migration to update enum
|
|
||||||
3. Use in activity creation code
|
|
||||||
4. Update activity display components if needed
|
|
||||||
|
|
||||||
## Git & Commits
|
|
||||||
|
|
||||||
- Use conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, etc.
|
|
||||||
- Keep commits focused on single changes
|
|
||||||
- Reference issue numbers when applicable
|
|
||||||
|
|
||||||
## PR Instructions
|
|
||||||
|
|
||||||
- Title format: `feat: description` or `fix: description`
|
|
||||||
- Always run `pnpm lint` and `pnpm typecheck` before committing
|
|
||||||
- Provide clear description of changes
|
|
||||||
- Include screenshots for UI changes
|
|
||||||
- Keep PRs focused on a single feature/fix
|
|
||||||
@@ -19,19 +19,22 @@ RUN pnpm config set store-dir ~/.pnpm-store
|
|||||||
|
|
||||||
# 2. Prune projects
|
# 2. Prune projects
|
||||||
FROM base AS pruner
|
FROM base AS pruner
|
||||||
# https://stackoverflow.com/questions/49681984/how-to-get-version-value-of-package-json-inside-of-dockerfile
|
|
||||||
# RUN export VERSION=$(npm run version)
|
|
||||||
|
|
||||||
ARG PROJECT
|
ARG PROJECT
|
||||||
|
|
||||||
# Set working directory
|
RUN apk add --no-cache git
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# It might be the path to <ROOT> turborepo
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Generate a partial monorepo with a pruned lockfile for a target workspace.
|
# Generate version from git (fallback if APP_VERSION not provided)
|
||||||
# Assuming "@acme/nextjs" is the name entered in the project's package.json: { name: "@acme/nextjs" }
|
RUN git fetch --tags --unshallow 2>/dev/null || git fetch --tags 2>/dev/null || true && \
|
||||||
|
AUTO_VERSION=$(git describe --tags --always --long 2>/dev/null | \
|
||||||
|
sed -E 's/^v?([0-9]+\.[0-9]+\.[0-9]+)-[0-9]+-g([a-f0-9]{7}).*/\1+\2/' | \
|
||||||
|
sed 's/^v//' || \
|
||||||
|
git rev-parse --short HEAD 2>/dev/null | head -c 7 || \
|
||||||
|
echo "unknown") && \
|
||||||
|
echo "$AUTO_VERSION" > /app/AUTO_VERSION
|
||||||
|
|
||||||
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
|
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
|
||||||
|
|
||||||
# 3. Build the project
|
# 3. Build the project
|
||||||
@@ -39,25 +42,22 @@ FROM base AS builder
|
|||||||
ARG PROJECT
|
ARG PROJECT
|
||||||
ARG APP_VERSION
|
ARG APP_VERSION
|
||||||
|
|
||||||
# Environment to skip .env validation on build
|
|
||||||
ENV CI=true
|
|
||||||
ENV NEXT_PUBLIC_APP_VERSION=${APP_VERSION}
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy lockfile and package.json's of isolated subworkspace
|
|
||||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||||
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||||
COPY --from=pruner /app/out/json/ .
|
COPY --from=pruner /app/out/json/ .
|
||||||
|
COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION
|
||||||
|
|
||||||
|
ENV CI=true
|
||||||
|
|
||||||
# First install the dependencies (as they change less often)
|
|
||||||
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
|
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# Copy source code of isolated subworkspace
|
|
||||||
COPY --from=pruner /app/out/full/ .
|
COPY --from=pruner /app/out/full/ .
|
||||||
|
|
||||||
|
# Use provided APP_VERSION or auto-generated from pruner stage
|
||||||
RUN pnpm build --filter=${PROJECT}
|
RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \
|
||||||
|
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=${PROJECT}
|
||||||
|
|
||||||
# # Copy static files to standalone directory
|
# # Copy static files to standalone directory
|
||||||
# RUN mkdir -p apps/web/.next/standalone/.next && \
|
# RUN mkdir -p apps/web/.next/standalone/.next && \
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: apps/web/Dockerfile
|
dockerfile: apps/web/Dockerfile
|
||||||
|
args:
|
||||||
|
APP_VERSION: ${APP_VERSION:-}
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,147 +1,23 @@
|
|||||||
import type { Subscription } from "@better-auth/stripe";
|
|
||||||
import type Stripe from "stripe";
|
|
||||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
||||||
import { stripe } from "@better-auth/stripe";
|
|
||||||
import { ChatOrPushProviderEnum } from "@novu/api/models/components";
|
|
||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
|
|
||||||
import { apiKey, genericOAuth } from "better-auth/plugins";
|
|
||||||
import { magicLink } from "better-auth/plugins/magic-link";
|
|
||||||
import { socialProviderList } from "better-auth/social-providers";
|
|
||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
import type { dbClient } from "@kan/db/client";
|
||||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
|
||||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
|
||||||
import * as userRepo from "@kan/db/repository/user.repo";
|
|
||||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
|
||||||
import * as schema from "@kan/db/schema";
|
import * as schema from "@kan/db/schema";
|
||||||
import { notificationClient, sendEmail } from "@kan/email";
|
import { sendEmail } from "@kan/email";
|
||||||
import { createEmailUnsubscribeLink } from "@kan/shared";
|
|
||||||
import { createStripeClient } from "@kan/stripe";
|
|
||||||
|
|
||||||
export const configuredProviders = socialProviderList.reduce<
|
import { createDatabaseHooks, createMiddlewareHooks } from "./hooks";
|
||||||
Record<
|
import { createPlugins } from "./plugins";
|
||||||
string,
|
import { configuredProviders } from "./providers";
|
||||||
{
|
|
||||||
clientId: string;
|
|
||||||
clientSecret: string;
|
|
||||||
appBundleIdentifier?: string;
|
|
||||||
tenantId?: string;
|
|
||||||
requireSelectAccount?: boolean;
|
|
||||||
clientKey?: string;
|
|
||||||
issuer?: string;
|
|
||||||
// Google-specific optional hints
|
|
||||||
hostedDomain?: string;
|
|
||||||
hd?: string;
|
|
||||||
}
|
|
||||||
>
|
|
||||||
>((acc, provider) => {
|
|
||||||
const id = process.env[`${provider.toUpperCase()}_CLIENT_ID`];
|
|
||||||
const secret = process.env[`${provider.toUpperCase()}_CLIENT_SECRET`];
|
|
||||||
if (id && id.length > 0 && secret && secret.length > 0) {
|
|
||||||
acc[provider] = { clientId: id, clientSecret: secret };
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
provider === "apple" &&
|
|
||||||
Object.keys(acc).includes("apple") &&
|
|
||||||
acc[provider]
|
|
||||||
) {
|
|
||||||
const bundleId =
|
|
||||||
process.env[`${provider.toUpperCase()}_APP_BUNDLE_IDENTIFIER`];
|
|
||||||
if (bundleId && bundleId.length > 0) {
|
|
||||||
acc[provider].appBundleIdentifier = bundleId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
provider === "gitlab" &&
|
|
||||||
Object.keys(acc).includes("gitlab") &&
|
|
||||||
acc[provider]
|
|
||||||
) {
|
|
||||||
const issuer = process.env[`${provider.toUpperCase()}_ISSUER`];
|
|
||||||
if (issuer && issuer.length > 0) {
|
|
||||||
acc[provider].issuer = issuer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
provider === "microsoft" &&
|
|
||||||
Object.keys(acc).includes("microsoft") &&
|
|
||||||
acc[provider]
|
|
||||||
) {
|
|
||||||
acc[provider].tenantId = "common";
|
|
||||||
acc[provider].requireSelectAccount = true;
|
|
||||||
}
|
|
||||||
// Add Google domain hint if allowed domains is configured
|
|
||||||
if (
|
|
||||||
provider === "google" &&
|
|
||||||
Object.keys(acc).includes("google") &&
|
|
||||||
acc[provider]
|
|
||||||
) {
|
|
||||||
const allowed = process.env.BETTER_AUTH_ALLOWED_DOMAINS?.split(",")
|
|
||||||
.map((d) => d.trim().toLowerCase())
|
|
||||||
.filter(Boolean);
|
|
||||||
if (allowed && allowed.length > 0) {
|
|
||||||
// Use the first domain as an authorization hint
|
|
||||||
acc[provider].hostedDomain = allowed[0];
|
|
||||||
acc[provider].hd = allowed[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
provider === "tiktok" &&
|
|
||||||
Object.keys(acc).includes("tiktok") &&
|
|
||||||
acc[provider]
|
|
||||||
) {
|
|
||||||
const key = process.env[`${provider.toUpperCase()}_CLIENT_KEY`];
|
|
||||||
if (key && key.length > 0) {
|
|
||||||
acc[provider].clientKey = key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
export const socialProvidersPlugin = () => ({
|
|
||||||
id: "social-providers-plugin",
|
|
||||||
endpoints: {
|
|
||||||
getSocialProviders: createAuthEndpoint(
|
|
||||||
"/social-providers",
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
},
|
|
||||||
async (ctx) => {
|
|
||||||
const providers = ctx.context.socialProviders.map((p) =>
|
|
||||||
p.id.toLowerCase(),
|
|
||||||
);
|
|
||||||
// Add OIDC provider if configured
|
|
||||||
if (
|
|
||||||
process.env.OIDC_CLIENT_ID &&
|
|
||||||
process.env.OIDC_CLIENT_SECRET &&
|
|
||||||
process.env.OIDC_DISCOVERY_URL
|
|
||||||
) {
|
|
||||||
providers.push("oidc");
|
|
||||||
}
|
|
||||||
return ctx.json(providers);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
async function downloadImage(url: string): Promise<Buffer> {
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to download image: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
return Buffer.from(await response.arrayBuffer());
|
|
||||||
}
|
|
||||||
|
|
||||||
export const initAuth = (db: dbClient) => {
|
export const initAuth = (db: dbClient) => {
|
||||||
return betterAuth({
|
return betterAuth({
|
||||||
secret: process.env.BETTER_AUTH_SECRET!,
|
secret: env("BETTER_AUTH_SECRET"),
|
||||||
baseURL: env("NEXT_PUBLIC_BASE_URL"),
|
baseURL: env("NEXT_PUBLIC_BASE_URL"),
|
||||||
trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
trustedOrigins: env("BETTER_AUTH_TRUSTED_ORIGINS")
|
||||||
? [
|
? [
|
||||||
env("NEXT_PUBLIC_BASE_URL") ?? "",
|
env("NEXT_PUBLIC_BASE_URL") ?? "",
|
||||||
...process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(","),
|
...(env("BETTER_AUTH_TRUSTED_ORIGINS")?.split(",") ?? []),
|
||||||
]
|
]
|
||||||
: [env("NEXT_PUBLIC_BASE_URL") ?? ""],
|
: [env("NEXT_PUBLIC_BASE_URL") ?? ""],
|
||||||
database: drizzleAdapter(db, {
|
database: drizzleAdapter(db, {
|
||||||
@@ -181,360 +57,9 @@ export const initAuth = (db: dbClient) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: createPlugins(db),
|
||||||
socialProvidersPlugin(),
|
databaseHooks: createDatabaseHooks(db),
|
||||||
...(process.env.NEXT_PUBLIC_KAN_ENV === "cloud"
|
hooks: createMiddlewareHooks(db),
|
||||||
? [
|
|
||||||
stripe({
|
|
||||||
stripeClient: createStripeClient(),
|
|
||||||
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
|
|
||||||
createCustomerOnSignUp: true,
|
|
||||||
subscription: {
|
|
||||||
enabled: true,
|
|
||||||
plans: [
|
|
||||||
{
|
|
||||||
name: "team",
|
|
||||||
priceId: process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID!,
|
|
||||||
annualDiscountPriceId:
|
|
||||||
process.env.STRIPE_TEAM_PLAN_YEARLY_PRICE_ID!,
|
|
||||||
freeTrial: {
|
|
||||||
days: 14,
|
|
||||||
onTrialStart: async (subscription) => {
|
|
||||||
await triggerWorkflow(db, "trial-start", subscription);
|
|
||||||
},
|
|
||||||
onTrialEnd: async ({ subscription }) => {
|
|
||||||
await triggerWorkflow(db, "trial-end", subscription);
|
|
||||||
},
|
|
||||||
onTrialExpired: async (subscription) => {
|
|
||||||
await triggerWorkflow(
|
|
||||||
db,
|
|
||||||
"trial-expired",
|
|
||||||
subscription,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "pro",
|
|
||||||
priceId: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID!,
|
|
||||||
annualDiscountPriceId:
|
|
||||||
process.env.STRIPE_PRO_PLAN_YEARLY_PRICE_ID!,
|
|
||||||
freeTrial: {
|
|
||||||
days: 14,
|
|
||||||
onTrialStart: async (subscription) => {
|
|
||||||
await triggerWorkflow(db, "trial-start", subscription);
|
|
||||||
},
|
|
||||||
onTrialEnd: async ({ subscription }) => {
|
|
||||||
await triggerWorkflow(db, "trial-end", subscription);
|
|
||||||
},
|
|
||||||
onTrialExpired: async (subscription) => {
|
|
||||||
await triggerWorkflow(
|
|
||||||
db,
|
|
||||||
"trial-expired",
|
|
||||||
subscription,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
authorizeReference: async (data) => {
|
|
||||||
const workspace = await workspaceRepo.getByPublicId(
|
|
||||||
db,
|
|
||||||
data.referenceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!workspace) {
|
|
||||||
return Promise.resolve(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isUserInWorkspace =
|
|
||||||
await workspaceRepo.isUserInWorkspace(
|
|
||||||
db,
|
|
||||||
data.user.id,
|
|
||||||
workspace.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
return isUserInWorkspace;
|
|
||||||
},
|
|
||||||
getCheckoutSessionParams: () => {
|
|
||||||
return {
|
|
||||||
params: {
|
|
||||||
allow_promotion_codes: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
onSubscriptionComplete: async ({
|
|
||||||
subscription,
|
|
||||||
stripeSubscription,
|
|
||||||
}) => {
|
|
||||||
// Set unlimited seats to true for pro plans
|
|
||||||
if (subscription.plan === "pro") {
|
|
||||||
await subscriptionRepo.updateByStripeSubscriptionId(
|
|
||||||
db,
|
|
||||||
stripeSubscription.id,
|
|
||||||
{
|
|
||||||
unlimitedSeats: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
`Pro subscription ${stripeSubscription.id} activated with unlimited seats`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const workspace = await workspaceRepo.getByPublicId(
|
|
||||||
db,
|
|
||||||
subscription.referenceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (workspace?.id) {
|
|
||||||
await memberRepo.unpauseAllMembers(db, workspace.id);
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Unpausing all members for workspace ${workspace.id}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
apiKey({
|
|
||||||
enableSessionForAPIKeys: true,
|
|
||||||
rateLimit: {
|
|
||||||
enabled: true,
|
|
||||||
timeWindow: 1000 * 60, // 1 minute
|
|
||||||
maxRequests: 100, // 100 requests per minute
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
magicLink({
|
|
||||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
|
||||||
sendMagicLink: async ({ email, url }) => {
|
|
||||||
if (url.includes("type=invite")) {
|
|
||||||
await sendEmail(
|
|
||||||
email,
|
|
||||||
"Invitation to join workspace",
|
|
||||||
"JOIN_WORKSPACE",
|
|
||||||
{
|
|
||||||
magicLoginUrl: url,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await sendEmail(email, "Sign in to kan.bn", "MAGIC_LINK", {
|
|
||||||
magicLoginUrl: url,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
// Generic OIDC provider
|
|
||||||
...(process.env.OIDC_CLIENT_ID &&
|
|
||||||
process.env.OIDC_CLIENT_SECRET &&
|
|
||||||
process.env.OIDC_DISCOVERY_URL
|
|
||||||
? [
|
|
||||||
genericOAuth({
|
|
||||||
config: [
|
|
||||||
{
|
|
||||||
providerId: "oidc",
|
|
||||||
clientId: process.env.OIDC_CLIENT_ID,
|
|
||||||
clientSecret: process.env.OIDC_CLIENT_SECRET,
|
|
||||||
discoveryUrl: process.env.OIDC_DISCOVERY_URL,
|
|
||||||
scopes: ["openid", "email", "profile"],
|
|
||||||
pkce: true,
|
|
||||||
mapProfileToUser: (profile: {
|
|
||||||
name?: string;
|
|
||||||
display_name?: string;
|
|
||||||
preferred_username?: string;
|
|
||||||
given_name?: string;
|
|
||||||
family_name?: string;
|
|
||||||
email?: string;
|
|
||||||
email_verified?: boolean;
|
|
||||||
sub?: string;
|
|
||||||
picture?: string;
|
|
||||||
avatar?: string;
|
|
||||||
}) => {
|
|
||||||
console.log("OIDC profile:", profile);
|
|
||||||
|
|
||||||
const name =
|
|
||||||
profile.name ??
|
|
||||||
profile.display_name ??
|
|
||||||
profile.preferred_username ??
|
|
||||||
(profile.given_name && profile.family_name
|
|
||||||
? `${profile.given_name} ${profile.family_name}`.trim()
|
|
||||||
: (profile.given_name ?? profile.family_name)) ??
|
|
||||||
profile.sub ??
|
|
||||||
"";
|
|
||||||
|
|
||||||
return {
|
|
||||||
email: profile.email,
|
|
||||||
name: name,
|
|
||||||
emailVerified: profile.email_verified ?? false,
|
|
||||||
image: profile.picture ?? profile.avatar ?? null,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
],
|
|
||||||
databaseHooks: {
|
|
||||||
user: {
|
|
||||||
create: {
|
|
||||||
async before(user) {
|
|
||||||
if (env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true") {
|
|
||||||
const pendingInvitation = await memberRepo.getByEmailAndStatus(
|
|
||||||
db,
|
|
||||||
user.email,
|
|
||||||
"invited",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!pendingInvitation) {
|
|
||||||
return Promise.resolve(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall through to any additional checks below
|
|
||||||
}
|
|
||||||
// Enforce allowed domains (OIDC/social) if configured
|
|
||||||
const allowed = process.env.BETTER_AUTH_ALLOWED_DOMAINS?.split(",")
|
|
||||||
.map((d) => d.trim().toLowerCase())
|
|
||||||
.filter(Boolean);
|
|
||||||
if (allowed && allowed.length > 0) {
|
|
||||||
const domain = user.email.split("@")[1]?.toLowerCase();
|
|
||||||
if (!domain || !allowed.includes(domain)) {
|
|
||||||
return Promise.resolve(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Promise.resolve(true);
|
|
||||||
},
|
|
||||||
async after(user) {
|
|
||||||
let avatarKey = user.image;
|
|
||||||
if (
|
|
||||||
user.image &&
|
|
||||||
!user.image.includes(process.env.NEXT_PUBLIC_STORAGE_DOMAIN!)
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const credentials =
|
|
||||||
env("S3_ACCESS_KEY_ID") && env("S3_SECRET_ACCESS_KEY")
|
|
||||||
? {
|
|
||||||
accessKeyId: env("S3_ACCESS_KEY_ID")!,
|
|
||||||
secretAccessKey: env("S3_SECRET_ACCESS_KEY")!,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const client = new S3Client({
|
|
||||||
region: env("S3_REGION") ?? "",
|
|
||||||
endpoint: env("S3_ENDPOINT") ?? "",
|
|
||||||
forcePathStyle: env("S3_FORCE_PATH_STYLE") === "true",
|
|
||||||
credentials,
|
|
||||||
});
|
|
||||||
|
|
||||||
const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"];
|
|
||||||
|
|
||||||
const fileExtension =
|
|
||||||
user.image.split(".").pop()?.split("?")[0] || "jpg";
|
|
||||||
const key = `${user.id}/avatar.${!allowedFileExtensions.includes(fileExtension) ? "jpg" : fileExtension}`;
|
|
||||||
|
|
||||||
const imageBuffer = await downloadImage(user.image);
|
|
||||||
|
|
||||||
await client.send(
|
|
||||||
new PutObjectCommand({
|
|
||||||
Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
|
||||||
Key: key,
|
|
||||||
Body: imageBuffer,
|
|
||||||
ContentType: `image/${!allowedFileExtensions.includes(fileExtension) ? "jpeg" : fileExtension}`,
|
|
||||||
ACL: "public-read",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
avatarKey = key;
|
|
||||||
|
|
||||||
await userRepo.update(db, user.id, {
|
|
||||||
image: key,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notificationClient) {
|
|
||||||
try {
|
|
||||||
const [firstName, ...rest] = user.name
|
|
||||||
.split(" ")
|
|
||||||
.filter(Boolean);
|
|
||||||
const lastName = rest.length ? rest.join(" ") : undefined;
|
|
||||||
const avatarUrl = avatarKey
|
|
||||||
? `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${avatarKey}`
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const unsubscribeUrl = await createEmailUnsubscribeLink(
|
|
||||||
user.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
await notificationClient.trigger({
|
|
||||||
to: {
|
|
||||||
subscriberId: user.id,
|
|
||||||
firstName: firstName,
|
|
||||||
lastName: lastName,
|
|
||||||
email: user.email,
|
|
||||||
avatar: avatarUrl,
|
|
||||||
data: {
|
|
||||||
emailVerified: user.emailVerified,
|
|
||||||
stripeCustomerId: user.stripeCustomerId,
|
|
||||||
createdAt: user.createdAt,
|
|
||||||
updatedAt: user.updatedAt,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
payload: {
|
|
||||||
emailUnsubscribeUrl: unsubscribeUrl,
|
|
||||||
},
|
|
||||||
workflowId: "user-signup",
|
|
||||||
});
|
|
||||||
|
|
||||||
await notificationClient.subscribers.credentials.update(
|
|
||||||
{
|
|
||||||
providerId: ChatOrPushProviderEnum.Discord,
|
|
||||||
credentials: {
|
|
||||||
webhookUrl: process.env.DISCORD_WEBHOOK_URL!,
|
|
||||||
},
|
|
||||||
integrationIdentifier: "discord",
|
|
||||||
},
|
|
||||||
user.id,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"Error adding user to notification client",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
hooks: {
|
|
||||||
after: createAuthMiddleware(async (ctx) => {
|
|
||||||
if (
|
|
||||||
ctx.path === "/magic-link/verify" &&
|
|
||||||
(ctx.query?.callbackURL as string | undefined)?.includes(
|
|
||||||
"type=invite",
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
const userId = ctx.context.newSession?.session.userId;
|
|
||||||
const callbackURL = ctx.query?.callbackURL as string | undefined;
|
|
||||||
const memberPublicId = callbackURL?.split("memberPublicId=")[1];
|
|
||||||
|
|
||||||
if (userId && memberPublicId) {
|
|
||||||
const member = await memberRepo.getByPublicId(db, memberPublicId);
|
|
||||||
|
|
||||||
if (member?.id) {
|
|
||||||
await memberRepo.acceptInvite(db, {
|
|
||||||
memberId: member.id,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
advanced: {
|
advanced: {
|
||||||
cookiePrefix: "kan",
|
cookiePrefix: "kan",
|
||||||
database: {
|
database: {
|
||||||
@@ -543,37 +68,3 @@ export const initAuth = (db: dbClient) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
async function triggerWorkflow(
|
|
||||||
db: dbClient,
|
|
||||||
workflowId: string,
|
|
||||||
subscription: Subscription,
|
|
||||||
cancellationDetails?: Stripe.Subscription.CancellationDetails | null,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
if (!subscription.stripeCustomerId || !notificationClient) return;
|
|
||||||
|
|
||||||
const user = await userRepo.getByStripeCustomerId(
|
|
||||||
db,
|
|
||||||
subscription.stripeCustomerId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!user || !notificationClient) return;
|
|
||||||
|
|
||||||
const unsubscribeUrl = await createEmailUnsubscribeLink(user.id);
|
|
||||||
|
|
||||||
await notificationClient.trigger({
|
|
||||||
to: {
|
|
||||||
subscriberId: user.id,
|
|
||||||
},
|
|
||||||
payload: {
|
|
||||||
...subscription,
|
|
||||||
cancellationDetails,
|
|
||||||
emailUnsubscribeUrl: unsubscribeUrl,
|
|
||||||
},
|
|
||||||
workflowId,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error triggering workflow", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "better-auth/client/plugins";
|
} from "better-auth/client/plugins";
|
||||||
import { createAuthClient } from "better-auth/react";
|
import { createAuthClient } from "better-auth/react";
|
||||||
|
|
||||||
import type { socialProvidersPlugin } from "./auth";
|
import type { socialProvidersPlugin } from "./providers";
|
||||||
|
|
||||||
const socialProvidersPluginClient = {
|
const socialProvidersPluginClient = {
|
||||||
id: "social-providers-plugin",
|
id: "social-providers-plugin",
|
||||||
|
|||||||
183
packages/auth/src/hooks.ts
Normal file
183
packages/auth/src/hooks.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||||
|
import { ChatOrPushProviderEnum } from "@novu/api/models/components";
|
||||||
|
import { createAuthMiddleware } from "better-auth/api";
|
||||||
|
import { env } from "next-runtime-env";
|
||||||
|
|
||||||
|
import type { dbClient } from "@kan/db/client";
|
||||||
|
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||||
|
import * as userRepo from "@kan/db/repository/user.repo";
|
||||||
|
import { notificationClient } from "@kan/email";
|
||||||
|
import { createEmailUnsubscribeLink } from "@kan/shared";
|
||||||
|
|
||||||
|
import { downloadImage } from "./utils";
|
||||||
|
|
||||||
|
type BetterAuthUser = {
|
||||||
|
id: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
email: string;
|
||||||
|
emailVerified: boolean;
|
||||||
|
name: string;
|
||||||
|
image?: string | null | undefined;
|
||||||
|
stripeCustomerId?: string | null | undefined;
|
||||||
|
} & Record<string, unknown>;
|
||||||
|
|
||||||
|
export function createDatabaseHooks(db: dbClient) {
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
create: {
|
||||||
|
async before(user: BetterAuthUser, _context: unknown) {
|
||||||
|
if (env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true") {
|
||||||
|
const pendingInvitation = await memberRepo.getByEmailAndStatus(
|
||||||
|
db,
|
||||||
|
user.email,
|
||||||
|
"invited",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!pendingInvitation) {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall through to any additional checks below
|
||||||
|
}
|
||||||
|
// Enforce allowed domains (OIDC/social) if configured
|
||||||
|
const allowed = process.env.BETTER_AUTH_ALLOWED_DOMAINS?.split(",")
|
||||||
|
.map((d) => d.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (allowed && allowed.length > 0) {
|
||||||
|
const domain = user.email.split("@")[1]?.toLowerCase();
|
||||||
|
if (!domain || !allowed.includes(domain)) {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.resolve(true);
|
||||||
|
},
|
||||||
|
async after(user: BetterAuthUser, _context: unknown) {
|
||||||
|
let avatarKey = user.image;
|
||||||
|
const storageDomain = process.env.NEXT_PUBLIC_STORAGE_DOMAIN;
|
||||||
|
if (
|
||||||
|
user.image &&
|
||||||
|
storageDomain &&
|
||||||
|
!user.image.includes(storageDomain)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const credentials =
|
||||||
|
env("S3_ACCESS_KEY_ID") && env("S3_SECRET_ACCESS_KEY")
|
||||||
|
? {
|
||||||
|
accessKeyId: env("S3_ACCESS_KEY_ID")!,
|
||||||
|
secretAccessKey: env("S3_SECRET_ACCESS_KEY")!,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const client = new S3Client({
|
||||||
|
region: env("S3_REGION") ?? "",
|
||||||
|
endpoint: env("S3_ENDPOINT") ?? "",
|
||||||
|
forcePathStyle: env("S3_FORCE_PATH_STYLE") === "true",
|
||||||
|
credentials,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"];
|
||||||
|
|
||||||
|
const fileExtension =
|
||||||
|
user.image.split(".").pop()?.split("?")[0] ?? "jpg";
|
||||||
|
const key = `${user.id}/avatar.${!allowedFileExtensions.includes(fileExtension) ? "jpg" : fileExtension}`;
|
||||||
|
|
||||||
|
const imageBuffer = await downloadImage(user.image);
|
||||||
|
|
||||||
|
await client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
||||||
|
Key: key,
|
||||||
|
Body: imageBuffer,
|
||||||
|
ContentType: `image/${!allowedFileExtensions.includes(fileExtension) ? "jpeg" : fileExtension}`,
|
||||||
|
ACL: "public-read",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
avatarKey = key;
|
||||||
|
|
||||||
|
await userRepo.update(db, user.id, {
|
||||||
|
image: key,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notificationClient) {
|
||||||
|
try {
|
||||||
|
const [firstName, ...rest] = (user.name || "")
|
||||||
|
.split(" ")
|
||||||
|
.filter(Boolean);
|
||||||
|
const lastName = rest.length ? rest.join(" ") : undefined;
|
||||||
|
const avatarUrl = avatarKey
|
||||||
|
? `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${avatarKey}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const unsubscribeUrl = await createEmailUnsubscribeLink(user.id);
|
||||||
|
|
||||||
|
await notificationClient.trigger({
|
||||||
|
to: {
|
||||||
|
subscriberId: user.id,
|
||||||
|
firstName: firstName,
|
||||||
|
lastName: lastName,
|
||||||
|
email: user.email,
|
||||||
|
avatar: avatarUrl,
|
||||||
|
data: {
|
||||||
|
emailVerified: user.emailVerified,
|
||||||
|
stripeCustomerId: user.stripeCustomerId,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
updatedAt: user.updatedAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
emailUnsubscribeUrl: unsubscribeUrl,
|
||||||
|
},
|
||||||
|
workflowId: "user-signup",
|
||||||
|
});
|
||||||
|
|
||||||
|
await notificationClient.subscribers.credentials.update(
|
||||||
|
{
|
||||||
|
providerId: ChatOrPushProviderEnum.Discord,
|
||||||
|
credentials: {
|
||||||
|
webhookUrl: env("DISCORD_WEBHOOK_URL"),
|
||||||
|
},
|
||||||
|
integrationIdentifier: "discord",
|
||||||
|
},
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error adding user to notification client", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMiddlewareHooks(db: dbClient) {
|
||||||
|
return {
|
||||||
|
after: createAuthMiddleware(async (ctx) => {
|
||||||
|
if (
|
||||||
|
ctx.path === "/magic-link/verify" &&
|
||||||
|
(ctx.query?.callbackURL as string | undefined)?.includes("type=invite")
|
||||||
|
) {
|
||||||
|
const userId = ctx.context.newSession?.session.userId;
|
||||||
|
const callbackURL = ctx.query?.callbackURL as string | undefined;
|
||||||
|
const memberPublicId = callbackURL?.split("memberPublicId=")[1];
|
||||||
|
|
||||||
|
if (userId && memberPublicId) {
|
||||||
|
const member = await memberRepo.getByPublicId(db, memberPublicId);
|
||||||
|
|
||||||
|
if (member?.id) {
|
||||||
|
await memberRepo.acceptInvite(db, {
|
||||||
|
memberId: member.id,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
225
packages/auth/src/plugins.ts
Normal file
225
packages/auth/src/plugins.ts
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import { stripe } from "@better-auth/stripe";
|
||||||
|
import { apiKey, genericOAuth } from "better-auth/plugins";
|
||||||
|
import { magicLink } from "better-auth/plugins/magic-link";
|
||||||
|
|
||||||
|
import type { dbClient } from "@kan/db/client";
|
||||||
|
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||||
|
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||||
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
|
import { sendEmail } from "@kan/email";
|
||||||
|
import { createStripeClient } from "@kan/stripe";
|
||||||
|
|
||||||
|
import { socialProvidersPlugin } from "./providers";
|
||||||
|
import { triggerWorkflow } from "./utils";
|
||||||
|
|
||||||
|
export function createPlugins(db: dbClient) {
|
||||||
|
return [
|
||||||
|
socialProvidersPlugin(),
|
||||||
|
...(process.env.NEXT_PUBLIC_KAN_ENV === "cloud"
|
||||||
|
? [
|
||||||
|
stripe({
|
||||||
|
stripeClient: createStripeClient(),
|
||||||
|
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
|
||||||
|
createCustomerOnSignUp: true,
|
||||||
|
subscription: {
|
||||||
|
enabled: true,
|
||||||
|
plans: [
|
||||||
|
{
|
||||||
|
name: "team",
|
||||||
|
priceId: process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID!,
|
||||||
|
annualDiscountPriceId:
|
||||||
|
process.env.STRIPE_TEAM_PLAN_YEARLY_PRICE_ID!,
|
||||||
|
freeTrial: {
|
||||||
|
days: 14,
|
||||||
|
onTrialStart: async (subscription) => {
|
||||||
|
await triggerWorkflow(db, "trial-start", subscription);
|
||||||
|
},
|
||||||
|
onTrialEnd: async ({ subscription }) => {
|
||||||
|
await triggerWorkflow(db, "trial-end", subscription);
|
||||||
|
},
|
||||||
|
onTrialExpired: async (subscription) => {
|
||||||
|
await triggerWorkflow(db, "trial-expired", subscription);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pro",
|
||||||
|
priceId: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID!,
|
||||||
|
annualDiscountPriceId:
|
||||||
|
process.env.STRIPE_PRO_PLAN_YEARLY_PRICE_ID!,
|
||||||
|
freeTrial: {
|
||||||
|
days: 14,
|
||||||
|
onTrialStart: async (subscription) => {
|
||||||
|
await triggerWorkflow(db, "trial-start", subscription);
|
||||||
|
},
|
||||||
|
onTrialEnd: async ({ subscription }) => {
|
||||||
|
await triggerWorkflow(db, "trial-end", subscription);
|
||||||
|
},
|
||||||
|
onTrialExpired: async (subscription) => {
|
||||||
|
await triggerWorkflow(db, "trial-expired", subscription);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
authorizeReference: async (data) => {
|
||||||
|
const workspace = await workspaceRepo.getByPublicId(
|
||||||
|
db,
|
||||||
|
data.referenceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUserInWorkspace = await workspaceRepo.isUserInWorkspace(
|
||||||
|
db,
|
||||||
|
data.user.id,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return isUserInWorkspace;
|
||||||
|
},
|
||||||
|
getCheckoutSessionParams: () => {
|
||||||
|
return {
|
||||||
|
params: {
|
||||||
|
allow_promotion_codes: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onSubscriptionComplete: async ({
|
||||||
|
subscription,
|
||||||
|
stripeSubscription,
|
||||||
|
}) => {
|
||||||
|
// Set unlimited seats to true for pro plans
|
||||||
|
if (subscription.plan === "pro") {
|
||||||
|
await subscriptionRepo.updateByStripeSubscriptionId(
|
||||||
|
db,
|
||||||
|
stripeSubscription.id,
|
||||||
|
{
|
||||||
|
unlimitedSeats: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`Pro subscription ${stripeSubscription.id} activated with unlimited seats`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const workspace = await workspaceRepo.getByPublicId(
|
||||||
|
db,
|
||||||
|
subscription.referenceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (workspace?.id) {
|
||||||
|
await memberRepo.unpauseAllMembers(db, workspace.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSubscriptionCancel: async ({
|
||||||
|
subscription,
|
||||||
|
cancellationDetails,
|
||||||
|
}) => {
|
||||||
|
await triggerWorkflow(
|
||||||
|
db,
|
||||||
|
"subscription-canceled",
|
||||||
|
subscription,
|
||||||
|
cancellationDetails,
|
||||||
|
);
|
||||||
|
|
||||||
|
// for cancelled subscriptions, we need to pause all members and set their workspace plan to free
|
||||||
|
const workspace = await workspaceRepo.getByPublicId(
|
||||||
|
db,
|
||||||
|
subscription.referenceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (workspace?.id) {
|
||||||
|
await memberRepo.pauseAllMembers(db, workspace.id);
|
||||||
|
await workspaceRepo.update(db, subscription.referenceId, {
|
||||||
|
plan: "free",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSubscriptionUpdate: async ({ subscription }) => {
|
||||||
|
await triggerWorkflow(db, "subscription-updated", subscription);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
apiKey({
|
||||||
|
enableSessionForAPIKeys: true,
|
||||||
|
rateLimit: {
|
||||||
|
enabled: true,
|
||||||
|
timeWindow: 1000 * 60, // 1 minute
|
||||||
|
maxRequests: 100, // 100 requests per minute
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
magicLink({
|
||||||
|
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||||
|
sendMagicLink: async ({ email, url }) => {
|
||||||
|
if (url.includes("type=invite")) {
|
||||||
|
await sendEmail(
|
||||||
|
email,
|
||||||
|
"Invitation to join workspace",
|
||||||
|
"JOIN_WORKSPACE",
|
||||||
|
{
|
||||||
|
magicLoginUrl: url,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await sendEmail(email, "Sign in to kan.bn", "MAGIC_LINK", {
|
||||||
|
magicLoginUrl: url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// Generic OIDC provider
|
||||||
|
...(process.env.OIDC_CLIENT_ID &&
|
||||||
|
process.env.OIDC_CLIENT_SECRET &&
|
||||||
|
process.env.OIDC_DISCOVERY_URL
|
||||||
|
? [
|
||||||
|
genericOAuth({
|
||||||
|
config: [
|
||||||
|
{
|
||||||
|
providerId: "oidc",
|
||||||
|
clientId: process.env.OIDC_CLIENT_ID,
|
||||||
|
clientSecret: process.env.OIDC_CLIENT_SECRET,
|
||||||
|
discoveryUrl: process.env.OIDC_DISCOVERY_URL,
|
||||||
|
scopes: ["openid", "email", "profile"],
|
||||||
|
pkce: true,
|
||||||
|
mapProfileToUser: (profile: {
|
||||||
|
name?: string;
|
||||||
|
display_name?: string;
|
||||||
|
preferred_username?: string;
|
||||||
|
given_name?: string;
|
||||||
|
family_name?: string;
|
||||||
|
email?: string;
|
||||||
|
email_verified?: boolean;
|
||||||
|
sub?: string;
|
||||||
|
picture?: string;
|
||||||
|
avatar?: string;
|
||||||
|
}) => {
|
||||||
|
console.log("OIDC profile:", profile);
|
||||||
|
|
||||||
|
const name =
|
||||||
|
profile.name ??
|
||||||
|
profile.display_name ??
|
||||||
|
profile.preferred_username ??
|
||||||
|
(profile.given_name && profile.family_name
|
||||||
|
? `${profile.given_name} ${profile.family_name}`.trim()
|
||||||
|
: (profile.given_name ?? profile.family_name)) ??
|
||||||
|
profile.sub ??
|
||||||
|
"";
|
||||||
|
|
||||||
|
return {
|
||||||
|
email: profile.email,
|
||||||
|
name: name,
|
||||||
|
emailVerified: profile.email_verified ?? false,
|
||||||
|
image: profile.picture ?? profile.avatar ?? null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
}
|
||||||
107
packages/auth/src/providers.ts
Normal file
107
packages/auth/src/providers.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { createAuthEndpoint } from "better-auth/api";
|
||||||
|
import { socialProviderList } from "better-auth/social-providers";
|
||||||
|
|
||||||
|
export const configuredProviders = socialProviderList.reduce<
|
||||||
|
Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
appBundleIdentifier?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
requireSelectAccount?: boolean;
|
||||||
|
clientKey?: string;
|
||||||
|
issuer?: string;
|
||||||
|
// Google-specific optional hints
|
||||||
|
hostedDomain?: string;
|
||||||
|
hd?: string;
|
||||||
|
}
|
||||||
|
>
|
||||||
|
>((acc, provider) => {
|
||||||
|
const id = process.env[`${provider.toUpperCase()}_CLIENT_ID`];
|
||||||
|
const secret = process.env[`${provider.toUpperCase()}_CLIENT_SECRET`];
|
||||||
|
if (id && id.length > 0 && secret && secret.length > 0) {
|
||||||
|
acc[provider] = { clientId: id, clientSecret: secret };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
provider === "apple" &&
|
||||||
|
Object.keys(acc).includes("apple") &&
|
||||||
|
acc[provider]
|
||||||
|
) {
|
||||||
|
const bundleId =
|
||||||
|
process.env[`${provider.toUpperCase()}_APP_BUNDLE_IDENTIFIER`];
|
||||||
|
if (bundleId && bundleId.length > 0) {
|
||||||
|
acc[provider].appBundleIdentifier = bundleId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
provider === "gitlab" &&
|
||||||
|
Object.keys(acc).includes("gitlab") &&
|
||||||
|
acc[provider]
|
||||||
|
) {
|
||||||
|
const issuer = process.env[`${provider.toUpperCase()}_ISSUER`];
|
||||||
|
if (issuer && issuer.length > 0) {
|
||||||
|
acc[provider].issuer = issuer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
provider === "microsoft" &&
|
||||||
|
Object.keys(acc).includes("microsoft") &&
|
||||||
|
acc[provider]
|
||||||
|
) {
|
||||||
|
acc[provider].tenantId = "common";
|
||||||
|
acc[provider].requireSelectAccount = true;
|
||||||
|
}
|
||||||
|
// Add Google domain hint if allowed domains is configured
|
||||||
|
if (
|
||||||
|
provider === "google" &&
|
||||||
|
Object.keys(acc).includes("google") &&
|
||||||
|
acc[provider]
|
||||||
|
) {
|
||||||
|
const allowed = process.env.BETTER_AUTH_ALLOWED_DOMAINS?.split(",")
|
||||||
|
.map((d) => d.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (allowed && allowed.length > 0) {
|
||||||
|
// Use the first domain as an authorization hint
|
||||||
|
acc[provider].hostedDomain = allowed[0];
|
||||||
|
acc[provider].hd = allowed[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
provider === "tiktok" &&
|
||||||
|
Object.keys(acc).includes("tiktok") &&
|
||||||
|
acc[provider]
|
||||||
|
) {
|
||||||
|
const key = process.env[`${provider.toUpperCase()}_CLIENT_KEY`];
|
||||||
|
if (key && key.length > 0) {
|
||||||
|
acc[provider].clientKey = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
export const socialProvidersPlugin = () => ({
|
||||||
|
id: "social-providers-plugin",
|
||||||
|
endpoints: {
|
||||||
|
getSocialProviders: createAuthEndpoint(
|
||||||
|
"/social-providers",
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
async (ctx) => {
|
||||||
|
const providers = ctx.context.socialProviders.map((p) =>
|
||||||
|
p.id.toLowerCase(),
|
||||||
|
);
|
||||||
|
// Add OIDC provider if configured
|
||||||
|
if (
|
||||||
|
process.env.OIDC_CLIENT_ID &&
|
||||||
|
process.env.OIDC_CLIENT_SECRET &&
|
||||||
|
process.env.OIDC_DISCOVERY_URL
|
||||||
|
) {
|
||||||
|
providers.push("oidc");
|
||||||
|
}
|
||||||
|
return ctx.json(providers);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
49
packages/auth/src/utils.ts
Normal file
49
packages/auth/src/utils.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { Subscription } from "@better-auth/stripe";
|
||||||
|
import type Stripe from "stripe";
|
||||||
|
|
||||||
|
import type { dbClient } from "@kan/db/client";
|
||||||
|
import * as userRepo from "@kan/db/repository/user.repo";
|
||||||
|
import { notificationClient } from "@kan/email";
|
||||||
|
import { createEmailUnsubscribeLink } from "@kan/shared";
|
||||||
|
|
||||||
|
export async function downloadImage(url: string): Promise<Buffer> {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to download image: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return Buffer.from(await response.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function triggerWorkflow(
|
||||||
|
db: dbClient,
|
||||||
|
workflowId: string,
|
||||||
|
subscription: Subscription,
|
||||||
|
cancellationDetails?: Stripe.Subscription.CancellationDetails | null,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
if (!subscription.stripeCustomerId || !notificationClient) return;
|
||||||
|
|
||||||
|
const user = await userRepo.getByStripeCustomerId(
|
||||||
|
db,
|
||||||
|
subscription.stripeCustomerId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user || !notificationClient) return;
|
||||||
|
|
||||||
|
const unsubscribeUrl = await createEmailUnsubscribeLink(user.id);
|
||||||
|
|
||||||
|
await notificationClient.trigger({
|
||||||
|
to: {
|
||||||
|
subscriberId: user.id,
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
...subscription,
|
||||||
|
cancellationDetails,
|
||||||
|
emailUnsubscribeUrl: unsubscribeUrl,
|
||||||
|
},
|
||||||
|
workflowId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error triggering workflow", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -121,3 +121,15 @@ export const unpauseAllMembers = async (db: dbClient, workspaceId: number) => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const pauseAllMembers = async (db: dbClient, workspaceId: number) => {
|
||||||
|
await db
|
||||||
|
.update(workspaceMembers)
|
||||||
|
.set({ status: "paused" })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workspaceMembers.workspaceId, workspaceId),
|
||||||
|
eq(workspaceMembers.status, "active"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user