chore: add pnpm workspace configuration with package definitions and overrides
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
# Local development secrets — copy to .dev.vars and fill in.
|
||||
BETTER_AUTH_SECRET=<openssl rand -base64 32>
|
||||
# Social providers — set real values to enable GitHub/Google sign-in
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
# Resend API key — when set, invitation/magic-link emails are sent via Resend.
|
||||
# When unset, email is logged to the console (local development only).
|
||||
RESEND_API_KEY=
|
||||
@@ -0,0 +1,12 @@
|
||||
# http://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = tab
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.yml]
|
||||
indent_style = space
|
||||
@@ -0,0 +1,171 @@
|
||||
# Logs
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Runtime data
|
||||
|
||||
pids
|
||||
_.pid
|
||||
_.seed
|
||||
\*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
coverage
|
||||
\*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
\*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
\*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
.cache/
|
||||
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.\*
|
||||
|
||||
# wrangler project
|
||||
|
||||
.dev.vars*
|
||||
!.dev.vars.example
|
||||
.env*
|
||||
!.env.example
|
||||
.wrangler/
|
||||
|
||||
# Better Auth CLI output / pending migration review files
|
||||
better-auth_migrations/
|
||||
d1-migrations/_pending.sql
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"printWidth": 140,
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"useTabs": true
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"files.associations": {
|
||||
"wrangler.json": "jsonc"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# Cloudflare Workers
|
||||
|
||||
STOP. Your knowledge of Cloudflare Workers APIs and limits may be outdated. Always retrieve current documentation before any Workers, KV, R2, D1, Durable Objects, Queues, Vectorize, AI, or Agents SDK task.
|
||||
|
||||
## Docs
|
||||
|
||||
- https://developers.cloudflare.com/workers/
|
||||
- MCP: `https://docs.mcp.cloudflare.com/mcp`
|
||||
|
||||
For all limits and quotas, retrieve from the product's `/platform/limits/` page. eg. `/workers/platform/limits`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Purpose |
|
||||
| --------------------- | ------------------------- |
|
||||
| `npx wrangler dev` | Local development |
|
||||
| `npx wrangler deploy` | Deploy to Cloudflare |
|
||||
| `npx wrangler types` | Generate TypeScript types |
|
||||
|
||||
Run `wrangler types` after changing bindings in wrangler.jsonc.
|
||||
|
||||
## Node.js Compatibility
|
||||
|
||||
https://developers.cloudflare.com/workers/runtime-apis/nodejs/
|
||||
|
||||
## Errors
|
||||
|
||||
- **Error 1102** (CPU/Memory exceeded): Retrieve limits from `/workers/platform/limits/`
|
||||
- **All errors**: https://developers.cloudflare.com/workers/observability/errors/
|
||||
|
||||
## Product Docs
|
||||
|
||||
Retrieve API references and limits from:
|
||||
`/kv/` · `/r2/` · `/d1/` · `/durable-objects/` · `/queues/` · `/vectorize/` · `/workers-ai/` · `/agents/`
|
||||
|
||||
## Best Practices (conditional)
|
||||
|
||||
If the application uses Durable Objects or Workflows, refer to the relevant best practices:
|
||||
|
||||
- Durable Objects: https://developers.cloudflare.com/durable-objects/best-practices/rules-of-durable-objects/
|
||||
- Workflows: https://developers.cloudflare.com/workflows/build/rules-of-workflows/
|
||||
@@ -0,0 +1,133 @@
|
||||
# Attendance worker
|
||||
|
||||
Cloudflare Worker API for **attendance** — [Better Auth](https://better-auth.com) on [D1](https://developers.cloudflare.com/d1/) served through an [Elysia](https://elysiajs.com) app using the experimental Cloudflare Worker adapter. Transactional email is rendered with [React Email](https://react.email) and delivered via [Resend](https://resend.com) (console in local dev).
|
||||
|
||||
## Stack
|
||||
|
||||
- **Framework**: [Elysia](https://elysiajs.com/integrations/cloudflare-worker.html) (`elysia/adapter/cloudflare-worker`) — mounts `auth.handler` at `/api/auth`, adds an `auth: true` route macro that injects typed `user`/`session`.
|
||||
- **Auth**: Better Auth `1.6.26` — email/password, GitHub & Google social sign-in, plus `username`, `magicLink`, `twoFactor` and `organization` plugins.
|
||||
- **Organizations**: Better Auth `organization` plugin with default owner/admin/member roles and native teams. Organizations may contain up to 25 teams and 100 accounts per team; dynamic roles remain disabled. Any authenticated user may create up to 10 organizations; 100 members and 100 pending invitations per organization; invitations expire after 48h; re-inviting an address cancels its previous pending invitation; deletion is owner-only and enabled.
|
||||
- **Email**: React Email templates rendered on the Worker (`@react-email/render` workerd build) and delivered over plain `fetch` to the Resend REST API. In local development (no `RESEND_API_KEY`), envelopes are printed to the worker console instead.
|
||||
- **Database**: Drizzle ORM on the D1 `DB` binding. Better Auth uses its generated schema in `src/db/auth-schema.ts`; attendance members, cameras, event history, and daily counters live in `src/db/attendance-schema.ts`.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
src/
|
||||
index.ts # Elysia app (entrypoint)
|
||||
env.d.ts # Type augmentation for secret bindings
|
||||
db/
|
||||
index.ts # Typed Drizzle client for env.DB
|
||||
schema.ts # Barrel exported to the runtime Drizzle client/Kit
|
||||
auth-schema.ts # Better Auth + organization schema (CLI-generated)
|
||||
attendance-schema.ts # Members, cameras, attendance events + daily summaries
|
||||
lib/
|
||||
auth/
|
||||
auth.ts # Runtime auth instance (Drizzle adapter)
|
||||
auth-options.ts # Shared auth config (plugins, providers, CORS origins)
|
||||
auth.generate.ts # CLI-only Drizzle config for `auth generate`
|
||||
invitation-email.ts# organization.sendInvitationEmail callback (URL + delivery)
|
||||
magic-link-email.ts# magicLink.sendMagicLink callback
|
||||
email/
|
||||
render-email.ts # React Email → { html, text }
|
||||
send-email.ts # Transport selection (Resend vs console)
|
||||
types.ts # EmailEnvelope / EmailTransport / EmailDeliveryError
|
||||
transports/
|
||||
resend.ts # Resend REST API over fetch (idempotency-key aware)
|
||||
console.ts # Local-dev-only console transport
|
||||
emails/
|
||||
components/EmailLayout.tsx
|
||||
OrganizationInvitationEmail.tsx
|
||||
MagicLinkEmail.tsx
|
||||
drizzle/
|
||||
0000_*.sql # Better Auth + organization migration
|
||||
0001_*.sql # Attendance domain migration
|
||||
0002_*.sql # Organization teams + account profile fields
|
||||
meta/ # Drizzle schema snapshots and journal
|
||||
drizzle.config.ts # Drizzle Kit schema/output configuration
|
||||
wrangler.jsonc # D1 binding, vars, compat flags
|
||||
```
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
vp install
|
||||
cp .dev.vars.example .dev.vars # fill in BETTER_AUTH_SECRET etc.
|
||||
vp run dev # http://localhost:8787
|
||||
```
|
||||
|
||||
Quick smoke test:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8787/api/auth/sign-up/email \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"[email protected]","password":"password123","name":"A"}'
|
||||
```
|
||||
|
||||
Organization endpoints require an `Origin` header, e.g. `-H "Origin: http://localhost:5173"`.
|
||||
|
||||
## Invitation emails
|
||||
|
||||
Better Auth does **not** send invitation emails or generate invitation URLs — the app does. `organization.sendInvitationEmail` (in `lib/auth/invitation-email.ts`):
|
||||
|
||||
1. Builds `{WEB_APP_URL}/invitations/{invitationId}`.
|
||||
2. Renders `emails/OrganizationInvitationEmail.tsx` to HTML + plain text.
|
||||
3. Delivers via `sendEmail` (Resend in production, console in local dev).
|
||||
|
||||
The web app route `/invitations/:invitationId` loads the invitation by ID (Better Auth validates the session email), lets the user accept/decline, then explicitly activates the joined organization. Magic links reuse the same render + transport pipeline.
|
||||
|
||||
Invitation IDs and auth links are never logged outside local development; the console transport exists only for `localhost`/`127.0.0.1` runs.
|
||||
|
||||
## Migrations
|
||||
|
||||
The barrel in `src/db/schema.ts` exports the generated auth schema and the hand-maintained attendance schema. Drizzle Kit generates committed SQL in `drizzle/`, and Wrangler applies it to D1 outside the request lifecycle.
|
||||
|
||||
```bash
|
||||
vp run db:generate # generate SQL after a schema change
|
||||
vp run db:check # validate migration history
|
||||
vp run db:migrate:local # apply to local dev DB (.wrangler/state)
|
||||
vp run db:migrate:remote # apply to the configured remote D1 database
|
||||
```
|
||||
|
||||
When Better Auth options or plugins change, regenerate only `auth-schema.ts`, review it, then generate a migration. Attendance tables stay isolated so the CLI cannot overwrite them:
|
||||
|
||||
```bash
|
||||
vp run auth:schema
|
||||
vp run db:generate
|
||||
vp run db:migrate:local
|
||||
```
|
||||
|
||||
`auth.generate.ts` exists because the CLI runs in Node without the Worker's D1 binding. It shares `auth-options.ts` with runtime auth and supplies an inert typed D1 client; schema generation does not execute database queries.
|
||||
|
||||
For a new remote environment, create the D1 database and replace the placeholder `database_id` in `wrangler.jsonc` before running the remote migration:
|
||||
|
||||
```bash
|
||||
wrangler d1 create attendance-db
|
||||
```
|
||||
|
||||
## Secrets & env
|
||||
|
||||
| Binding | Type | Notes |
|
||||
| ------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `DB` | D1 | `attendance-db` |
|
||||
| `BETTER_AUTH_URL` | var | Base URL, `http://localhost:8787` in dev |
|
||||
| `TRUSTED_ORIGINS` | var | Comma-separated extra CORS origins (e.g. `http://localhost:5173`) |
|
||||
| `WEB_APP_URL` | var | Web app base URL used to build invitation/magic-link URLs (e.g. `http://localhost:5173`) |
|
||||
| `EMAIL_FROM` | var | Sender for transactional email, e.g. `Attendance <[email protected]>` |
|
||||
| `EMAIL_REPLY_TO` | var | Optional reply-to for transactional email |
|
||||
| `BETTER_AUTH_SECRET` | **secret** | `wrangler secret put BETTER_AUTH_SECRET`; ≥32 chars |
|
||||
| `RESEND_API_KEY` | **secret** | `wrangler secret put RESEND_API_KEY`; enables production email delivery. Without it, email is only printed to the console in local dev. |
|
||||
| `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` | **secrets** | optional — enables GitHub sign-in |
|
||||
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | **secrets** | optional — enables Google sign-in |
|
||||
|
||||
> `src/lib/auth/auth-options.ts` builds `socialProviders` conditionally, so providers are only registered when their secrets are set.
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
vp run cf-typegen # after changing wrangler.jsonc bindings
|
||||
wrangler secret put BETTER_AUTH_SECRET
|
||||
wrangler secret put RESEND_API_KEY # required for production email
|
||||
vp run db:migrate:remote
|
||||
vp run deploy
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "sqlite",
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
strict: true,
|
||||
verbose: true,
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
CREATE TABLE `account` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`account_id` text NOT NULL,
|
||||
`provider_id` text NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`access_token` text,
|
||||
`refresh_token` text,
|
||||
`id_token` text,
|
||||
`access_token_expires_at` integer,
|
||||
`refresh_token_expires_at` integer,
|
||||
`scope` text,
|
||||
`password` text,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `account_userId_idx` ON `account` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `invitation` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
`email` text NOT NULL,
|
||||
`role` text,
|
||||
`status` text DEFAULT 'pending' NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`inviter_id` text NOT NULL,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`inviter_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `invitation_organizationId_idx` ON `invitation` (`organization_id`);--> statement-breakpoint
|
||||
CREATE INDEX `invitation_email_idx` ON `invitation` (`email`);--> statement-breakpoint
|
||||
CREATE TABLE `member` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`role` text DEFAULT 'member' NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `member_organizationId_idx` ON `member` (`organization_id`);--> statement-breakpoint
|
||||
CREATE INDEX `member_userId_idx` ON `member` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `organization` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`slug` text NOT NULL,
|
||||
`logo` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`metadata` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `organization_slug_unique` ON `organization` (`slug`);--> statement-breakpoint
|
||||
CREATE TABLE `session` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
`token` text NOT NULL,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
`ip_address` text,
|
||||
`user_agent` text,
|
||||
`user_id` text NOT NULL,
|
||||
`active_organization_id` text,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint
|
||||
CREATE INDEX `session_userId_idx` ON `session` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `two_factor` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`secret` text NOT NULL,
|
||||
`backup_codes` text NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`verified` integer DEFAULT true,
|
||||
`failed_verification_count` integer DEFAULT 0,
|
||||
`locked_until` integer,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `twoFactor_secret_idx` ON `two_factor` (`secret`);--> statement-breakpoint
|
||||
CREATE INDEX `twoFactor_userId_idx` ON `two_factor` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `user` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`email` text NOT NULL,
|
||||
`email_verified` integer DEFAULT false NOT NULL,
|
||||
`image` text,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`username` text,
|
||||
`display_username` text,
|
||||
`two_factor_enabled` integer DEFAULT false,
|
||||
`first_name` text NOT NULL,
|
||||
`last_name` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `user_username_unique` ON `user` (`username`);--> statement-breakpoint
|
||||
CREATE TABLE `verification` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`identifier` text NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`);
|
||||
@@ -0,0 +1,73 @@
|
||||
CREATE TABLE `attendance_daily_summary` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
`attendance_date` text NOT NULL,
|
||||
`checked_in_count` integer DEFAULT 0 NOT NULL,
|
||||
`checked_out_count` integer DEFAULT 0 NOT NULL,
|
||||
`present_count` integer DEFAULT 0 NOT NULL,
|
||||
`late_count` integer DEFAULT 0 NOT NULL,
|
||||
`absent_count` integer DEFAULT 0 NOT NULL,
|
||||
`peak_occupancy` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `attendance_daily_summary_org_date_uidx` ON `attendance_daily_summary` (`organization_id`,`attendance_date`);--> statement-breakpoint
|
||||
CREATE TABLE `attendance_event` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
`member_id` text NOT NULL,
|
||||
`camera_id` text,
|
||||
`direction` text NOT NULL,
|
||||
`captured_at` integer NOT NULL,
|
||||
`method` text NOT NULL,
|
||||
`confidence` real,
|
||||
`notes` text,
|
||||
`created_by_user_id` text,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`member_id`) REFERENCES `attendance_member`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`camera_id`) REFERENCES `camera`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
FOREIGN KEY (`created_by_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `attendance_event_org_captured_idx` ON `attendance_event` (`organization_id`,`captured_at`);--> statement-breakpoint
|
||||
CREATE INDEX `attendance_event_member_captured_idx` ON `attendance_event` (`member_id`,`captured_at`);--> statement-breakpoint
|
||||
CREATE INDEX `attendance_event_camera_captured_idx` ON `attendance_event` (`camera_id`,`captured_at`);--> statement-breakpoint
|
||||
CREATE TABLE `attendance_member` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
`member_code` text NOT NULL,
|
||||
`first_name` text NOT NULL,
|
||||
`last_name` text NOT NULL,
|
||||
`email` text,
|
||||
`phone` text,
|
||||
`department` text,
|
||||
`photo_url` text,
|
||||
`status` text DEFAULT 'active' NOT NULL,
|
||||
`registered_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `attendance_member_org_code_uidx` ON `attendance_member` (`organization_id`,`member_code`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `attendance_member_org_email_uidx` ON `attendance_member` (`organization_id`,`email`);--> statement-breakpoint
|
||||
CREATE INDEX `attendance_member_org_status_idx` ON `attendance_member` (`organization_id`,`status`);--> statement-breakpoint
|
||||
CREATE TABLE `camera` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`location` text NOT NULL,
|
||||
`zone` text,
|
||||
`stream_url` text,
|
||||
`status` text DEFAULT 'offline' NOT NULL,
|
||||
`last_seen_at` integer,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `camera_org_name_uidx` ON `camera` (`organization_id`,`name`);--> statement-breakpoint
|
||||
CREATE INDEX `camera_org_status_idx` ON `camera` (`organization_id`,`status`);
|
||||
@@ -0,0 +1,26 @@
|
||||
CREATE TABLE `team` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer,
|
||||
`description` text,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `team_organizationId_idx` ON `team` (`organization_id`);--> statement-breakpoint
|
||||
CREATE TABLE `team_member` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`team_id` text NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`created_at` integer,
|
||||
FOREIGN KEY (`team_id`) REFERENCES `team`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `teamMember_teamId_idx` ON `team_member` (`team_id`);--> statement-breakpoint
|
||||
CREATE INDEX `teamMember_userId_idx` ON `team_member` (`user_id`);--> statement-breakpoint
|
||||
ALTER TABLE `invitation` ADD `team_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `session` ADD `active_team_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `job_title` text;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `phone` text;
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE `member_gallery_photo` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`slot` integer NOT NULL,
|
||||
`object_key` text NOT NULL,
|
||||
`original_name` text NOT NULL,
|
||||
`mime_type` text NOT NULL,
|
||||
`size` integer NOT NULL,
|
||||
`uploaded_by_user_id` text,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`uploaded_by_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `member_gallery_photo_user_slot_uidx` ON `member_gallery_photo` (`user_id`,`slot`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `member_gallery_photo_object_key_uidx` ON `member_gallery_photo` (`object_key`);--> statement-breakpoint
|
||||
CREATE INDEX `member_gallery_photo_user_created_idx` ON `member_gallery_photo` (`user_id`,`created_at`);
|
||||
@@ -0,0 +1,714 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "edf19f43-5474-41ee-9fb2-c05f24c10559",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"account": {
|
||||
"name": "account",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"account_userId_idx": {
|
||||
"name": "account_userId_idx",
|
||||
"columns": ["user_id"],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"account_user_id_user_id_fk": {
|
||||
"name": "account_user_id_user_id_fk",
|
||||
"tableFrom": "account",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"invitation": {
|
||||
"name": "invitation",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"organization_id": {
|
||||
"name": "organization_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
},
|
||||
"inviter_id": {
|
||||
"name": "inviter_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"invitation_organizationId_idx": {
|
||||
"name": "invitation_organizationId_idx",
|
||||
"columns": ["organization_id"],
|
||||
"isUnique": false
|
||||
},
|
||||
"invitation_email_idx": {
|
||||
"name": "invitation_email_idx",
|
||||
"columns": ["email"],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"invitation_organization_id_organization_id_fk": {
|
||||
"name": "invitation_organization_id_organization_id_fk",
|
||||
"tableFrom": "invitation",
|
||||
"tableTo": "organization",
|
||||
"columnsFrom": ["organization_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"invitation_inviter_id_user_id_fk": {
|
||||
"name": "invitation_inviter_id_user_id_fk",
|
||||
"tableFrom": "invitation",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": ["inviter_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"member": {
|
||||
"name": "member",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"organization_id": {
|
||||
"name": "organization_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'member'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"member_organizationId_idx": {
|
||||
"name": "member_organizationId_idx",
|
||||
"columns": ["organization_id"],
|
||||
"isUnique": false
|
||||
},
|
||||
"member_userId_idx": {
|
||||
"name": "member_userId_idx",
|
||||
"columns": ["user_id"],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"member_organization_id_organization_id_fk": {
|
||||
"name": "member_organization_id_organization_id_fk",
|
||||
"tableFrom": "member",
|
||||
"tableTo": "organization",
|
||||
"columnsFrom": ["organization_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"member_user_id_user_id_fk": {
|
||||
"name": "member_user_id_user_id_fk",
|
||||
"tableFrom": "member",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"organization": {
|
||||
"name": "organization",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"logo": {
|
||||
"name": "logo",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"organization_slug_unique": {
|
||||
"name": "organization_slug_unique",
|
||||
"columns": ["slug"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"session": {
|
||||
"name": "session",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"active_organization_id": {
|
||||
"name": "active_organization_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"session_token_unique": {
|
||||
"name": "session_token_unique",
|
||||
"columns": ["token"],
|
||||
"isUnique": true
|
||||
},
|
||||
"session_userId_idx": {
|
||||
"name": "session_userId_idx",
|
||||
"columns": ["user_id"],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"two_factor": {
|
||||
"name": "two_factor",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"secret": {
|
||||
"name": "secret",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"backup_codes": {
|
||||
"name": "backup_codes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"verified": {
|
||||
"name": "verified",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"failed_verification_count": {
|
||||
"name": "failed_verification_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"locked_until": {
|
||||
"name": "locked_until",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"twoFactor_secret_idx": {
|
||||
"name": "twoFactor_secret_idx",
|
||||
"columns": ["secret"],
|
||||
"isUnique": false
|
||||
},
|
||||
"twoFactor_userId_idx": {
|
||||
"name": "twoFactor_userId_idx",
|
||||
"columns": ["user_id"],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"two_factor_user_id_user_id_fk": {
|
||||
"name": "two_factor_user_id_user_id_fk",
|
||||
"tableFrom": "two_factor",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"user": {
|
||||
"name": "user",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"display_username": {
|
||||
"name": "display_username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"two_factor_enabled": {
|
||||
"name": "two_factor_enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"first_name": {
|
||||
"name": "first_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_name": {
|
||||
"name": "last_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"columns": ["email"],
|
||||
"isUnique": true
|
||||
},
|
||||
"user_username_unique": {
|
||||
"name": "user_username_unique",
|
||||
"columns": ["username"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"verification": {
|
||||
"name": "verification",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"verification_identifier_idx": {
|
||||
"name": "verification_identifier_idx",
|
||||
"columns": ["identifier"],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1786701153928,
|
||||
"tag": "0000_moaning_ted_forrester",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1786702695784,
|
||||
"tag": "0001_public_zemo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1786703596872,
|
||||
"tag": "0002_tidy_ultimo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1786729550878,
|
||||
"tag": "0003_windy_reptil",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "attendance-worker",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"start": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"cf-typegen": "wrangler types",
|
||||
"auth:schema": "auth generate --config src/lib/auth/auth.generate.ts --yes --output src/db/auth-schema.ts",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:check": "drizzle-kit check",
|
||||
"db:migrate:local": "wrangler d1 migrations apply attendance-db --local",
|
||||
"db:migrate:remote": "wrangler d1 migrations apply attendance-db --remote",
|
||||
"db:seed:local": "wrangler d1 execute attendance-db --local --file seeds/testing.sql"
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysiajs/cors": "^1.4.2",
|
||||
"@react-email/components": "^1.0.12",
|
||||
"@react-email/render": "^2.1.0",
|
||||
"@sinclair/typebox": "^0.34.52",
|
||||
"better-auth": "^1.6.26",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"elysia": "^1.4.29",
|
||||
"openapi-types": "^12.1.3",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"auth": "1.6.26",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"typescript": "^5.5.2",
|
||||
"wrangler": "^4.120.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hello, World!</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1 id="heading"></h1>
|
||||
<p>
|
||||
This page comes from a static asset stored at `public/index.html` as configured in
|
||||
`wrangler.jsonc`.
|
||||
</p>
|
||||
<script>
|
||||
fetch("/message")
|
||||
.then((resp) => resp.text())
|
||||
.then((text) => {
|
||||
const h1 = document.getElementById("heading");
|
||||
h1.textContent = text;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,110 @@
|
||||
-- Local testing data for the most recently active organization.
|
||||
-- All seeded accounts use the password: TestAccount123!
|
||||
-- Safe to rerun: stable IDs and upserts prevent duplicate seed records.
|
||||
|
||||
INSERT INTO `user` (
|
||||
`id`, `name`, `email`, `email_verified`, `image`, `created_at`, `updated_at`,
|
||||
`username`, `display_username`, `two_factor_enabled`, `first_name`, `last_name`,
|
||||
`job_title`, `phone`
|
||||
) VALUES
|
||||
('seed-user-priya', 'Priya Sharma', '[email protected]', 1, NULL, CAST(unixepoch('subsecond') * 1000 AS integer) - 7776000000, CAST(unixepoch('subsecond') * 1000 AS integer), 'priya.sharma.seed', 'priya.sharma', 0, 'Priya', 'Sharma', 'Operations manager', '+91 98765 41001'),
|
||||
('seed-user-arjun', 'Arjun Mehta', '[email protected]', 1, NULL, CAST(unixepoch('subsecond') * 1000 AS integer) - 6912000000, CAST(unixepoch('subsecond') * 1000 AS integer), 'arjun.mehta.seed', 'arjun.mehta', 0, 'Arjun', 'Mehta', 'Frontend engineer', '+91 98765 41002'),
|
||||
('seed-user-neha', 'Neha Kapoor', '[email protected]', 1, NULL, CAST(unixepoch('subsecond') * 1000 AS integer) - 6048000000, CAST(unixepoch('subsecond') * 1000 AS integer), 'neha.kapoor.seed', 'neha.kapoor', 0, 'Neha', 'Kapoor', 'Backend engineer', '+91 98765 41003'),
|
||||
('seed-user-rohan', 'Rohan Verma', '[email protected]', 1, NULL, CAST(unixepoch('subsecond') * 1000 AS integer) - 5184000000, CAST(unixepoch('subsecond') * 1000 AS integer), 'rohan.verma.seed', 'rohan.verma', 0, 'Rohan', 'Verma', 'Field technician', '+91 98765 41004'),
|
||||
('seed-user-isha', 'Isha Patel', '[email protected]', 1, NULL, CAST(unixepoch('subsecond') * 1000 AS integer) - 4320000000, CAST(unixepoch('subsecond') * 1000 AS integer), 'isha.patel.seed', 'isha.patel', 0, 'Isha', 'Patel', 'People operations specialist', '+91 98765 41005'),
|
||||
('seed-user-kabir', 'Kabir Singh', '[email protected]', 1, NULL, CAST(unixepoch('subsecond') * 1000 AS integer) - 3456000000, CAST(unixepoch('subsecond') * 1000 AS integer), 'kabir.singh.seed', 'kabir.singh', 0, 'Kabir', 'Singh', 'Support lead', '+91 98765 41006'),
|
||||
('seed-user-meera', 'Meera Nair', '[email protected]', 1, NULL, CAST(unixepoch('subsecond') * 1000 AS integer) - 2592000000, CAST(unixepoch('subsecond') * 1000 AS integer), 'meera.nair.seed', 'meera.nair', 0, 'Meera', 'Nair', 'Product designer', '+91 98765 41007'),
|
||||
('seed-user-vikram', 'Vikram Rao', '[email protected]', 1, NULL, CAST(unixepoch('subsecond') * 1000 AS integer) - 1728000000, CAST(unixepoch('subsecond') * 1000 AS integer), 'vikram.rao.seed', 'vikram.rao', 0, 'Vikram', 'Rao', 'QA engineer', '+91 98765 41008')
|
||||
ON CONFLICT(`id`) DO UPDATE SET
|
||||
`name` = excluded.`name`,
|
||||
`email` = excluded.`email`,
|
||||
`email_verified` = excluded.`email_verified`,
|
||||
`username` = excluded.`username`,
|
||||
`display_username` = excluded.`display_username`,
|
||||
`first_name` = excluded.`first_name`,
|
||||
`last_name` = excluded.`last_name`,
|
||||
`job_title` = excluded.`job_title`,
|
||||
`phone` = excluded.`phone`,
|
||||
`updated_at` = excluded.`updated_at`;
|
||||
|
||||
INSERT INTO `account` (
|
||||
`id`, `account_id`, `provider_id`, `user_id`, `password`, `created_at`, `updated_at`
|
||||
) VALUES
|
||||
('seed-account-priya', 'seed-user-priya', 'credential', 'seed-user-priya', '0357ceae4118d3288653dbcc7155261d:3aeac712b52a1bfe63b2f485cf93b139bfdc8aa5aa51c804ebc22981648c249dda55375b61b9e71214e5b86d138fd1e94ce29c9ac84cec403b093b0eb233757a', CAST(unixepoch('subsecond') * 1000 AS integer) - 7776000000, CAST(unixepoch('subsecond') * 1000 AS integer)),
|
||||
('seed-account-arjun', 'seed-user-arjun', 'credential', 'seed-user-arjun', '0357ceae4118d3288653dbcc7155261d:3aeac712b52a1bfe63b2f485cf93b139bfdc8aa5aa51c804ebc22981648c249dda55375b61b9e71214e5b86d138fd1e94ce29c9ac84cec403b093b0eb233757a', CAST(unixepoch('subsecond') * 1000 AS integer) - 6912000000, CAST(unixepoch('subsecond') * 1000 AS integer)),
|
||||
('seed-account-neha', 'seed-user-neha', 'credential', 'seed-user-neha', '0357ceae4118d3288653dbcc7155261d:3aeac712b52a1bfe63b2f485cf93b139bfdc8aa5aa51c804ebc22981648c249dda55375b61b9e71214e5b86d138fd1e94ce29c9ac84cec403b093b0eb233757a', CAST(unixepoch('subsecond') * 1000 AS integer) - 6048000000, CAST(unixepoch('subsecond') * 1000 AS integer)),
|
||||
('seed-account-rohan', 'seed-user-rohan', 'credential', 'seed-user-rohan', '0357ceae4118d3288653dbcc7155261d:3aeac712b52a1bfe63b2f485cf93b139bfdc8aa5aa51c804ebc22981648c249dda55375b61b9e71214e5b86d138fd1e94ce29c9ac84cec403b093b0eb233757a', CAST(unixepoch('subsecond') * 1000 AS integer) - 5184000000, CAST(unixepoch('subsecond') * 1000 AS integer)),
|
||||
('seed-account-isha', 'seed-user-isha', 'credential', 'seed-user-isha', '0357ceae4118d3288653dbcc7155261d:3aeac712b52a1bfe63b2f485cf93b139bfdc8aa5aa51c804ebc22981648c249dda55375b61b9e71214e5b86d138fd1e94ce29c9ac84cec403b093b0eb233757a', CAST(unixepoch('subsecond') * 1000 AS integer) - 4320000000, CAST(unixepoch('subsecond') * 1000 AS integer)),
|
||||
('seed-account-kabir', 'seed-user-kabir', 'credential', 'seed-user-kabir', '0357ceae4118d3288653dbcc7155261d:3aeac712b52a1bfe63b2f485cf93b139bfdc8aa5aa51c804ebc22981648c249dda55375b61b9e71214e5b86d138fd1e94ce29c9ac84cec403b093b0eb233757a', CAST(unixepoch('subsecond') * 1000 AS integer) - 3456000000, CAST(unixepoch('subsecond') * 1000 AS integer)),
|
||||
('seed-account-meera', 'seed-user-meera', 'credential', 'seed-user-meera', '0357ceae4118d3288653dbcc7155261d:3aeac712b52a1bfe63b2f485cf93b139bfdc8aa5aa51c804ebc22981648c249dda55375b61b9e71214e5b86d138fd1e94ce29c9ac84cec403b093b0eb233757a', CAST(unixepoch('subsecond') * 1000 AS integer) - 2592000000, CAST(unixepoch('subsecond') * 1000 AS integer)),
|
||||
('seed-account-vikram', 'seed-user-vikram', 'credential', 'seed-user-vikram', '0357ceae4118d3288653dbcc7155261d:3aeac712b52a1bfe63b2f485cf93b139bfdc8aa5aa51c804ebc22981648c249dda55375b61b9e71214e5b86d138fd1e94ce29c9ac84cec403b093b0eb233757a', CAST(unixepoch('subsecond') * 1000 AS integer) - 1728000000, CAST(unixepoch('subsecond') * 1000 AS integer))
|
||||
ON CONFLICT(`id`) DO UPDATE SET
|
||||
`password` = excluded.`password`,
|
||||
`updated_at` = excluded.`updated_at`;
|
||||
|
||||
WITH
|
||||
`target_organization` AS (
|
||||
SELECT COALESCE(
|
||||
(SELECT `active_organization_id` FROM `session` WHERE `active_organization_id` IS NOT NULL ORDER BY `updated_at` DESC LIMIT 1),
|
||||
(SELECT `id` FROM `organization` ORDER BY `created_at` LIMIT 1)
|
||||
) AS `id`
|
||||
),
|
||||
`seed_members` (`member_id`, `user_id`, `role`, `created_at`) AS (
|
||||
VALUES
|
||||
('seed-member-priya', 'seed-user-priya', 'admin', CAST(unixepoch('subsecond') * 1000 AS integer) - 7776000000),
|
||||
('seed-member-arjun', 'seed-user-arjun', 'member', CAST(unixepoch('subsecond') * 1000 AS integer) - 6912000000),
|
||||
('seed-member-neha', 'seed-user-neha', 'member', CAST(unixepoch('subsecond') * 1000 AS integer) - 6048000000),
|
||||
('seed-member-rohan', 'seed-user-rohan', 'member', CAST(unixepoch('subsecond') * 1000 AS integer) - 5184000000),
|
||||
('seed-member-isha', 'seed-user-isha', 'member', CAST(unixepoch('subsecond') * 1000 AS integer) - 4320000000),
|
||||
('seed-member-kabir', 'seed-user-kabir', 'member', CAST(unixepoch('subsecond') * 1000 AS integer) - 3456000000),
|
||||
('seed-member-meera', 'seed-user-meera', 'member', CAST(unixepoch('subsecond') * 1000 AS integer) - 2592000000),
|
||||
('seed-member-vikram', 'seed-user-vikram', 'member', CAST(unixepoch('subsecond') * 1000 AS integer) - 1728000000)
|
||||
)
|
||||
INSERT INTO `member` (`id`, `organization_id`, `user_id`, `role`, `created_at`)
|
||||
SELECT `seed_members`.`member_id`, `target_organization`.`id`, `seed_members`.`user_id`, `seed_members`.`role`, `seed_members`.`created_at`
|
||||
FROM `seed_members` CROSS JOIN `target_organization`
|
||||
WHERE `target_organization`.`id` IS NOT NULL
|
||||
ON CONFLICT(`id`) DO UPDATE SET
|
||||
`organization_id` = excluded.`organization_id`,
|
||||
`user_id` = excluded.`user_id`,
|
||||
`role` = excluded.`role`;
|
||||
|
||||
WITH
|
||||
`target_organization` AS (
|
||||
SELECT COALESCE(
|
||||
(SELECT `active_organization_id` FROM `session` WHERE `active_organization_id` IS NOT NULL ORDER BY `updated_at` DESC LIMIT 1),
|
||||
(SELECT `id` FROM `organization` ORDER BY `created_at` LIMIT 1)
|
||||
) AS `id`
|
||||
),
|
||||
`seed_teams` (`team_id`, `name`, `created_at`, `description`) AS (
|
||||
VALUES
|
||||
('seed-team-operations', 'Operations', CAST(unixepoch('subsecond') * 1000 AS integer) - 1209600000, 'Coordinates daily attendance operations and reporting.'),
|
||||
('seed-team-engineering', 'Engineering', CAST(unixepoch('subsecond') * 1000 AS integer) - 1036800000, 'Builds and maintains the attendance platform.'),
|
||||
('seed-team-field-support', 'Field support', CAST(unixepoch('subsecond') * 1000 AS integer) - 864000000, 'Installs and supports cameras and on-site devices.'),
|
||||
('seed-team-people', 'People & culture', CAST(unixepoch('subsecond') * 1000 AS integer) - 691200000, 'Supports onboarding, policy, and employee experience.')
|
||||
)
|
||||
INSERT INTO `team` (`id`, `name`, `organization_id`, `created_at`, `updated_at`, `description`)
|
||||
SELECT `seed_teams`.`team_id`, `seed_teams`.`name`, `target_organization`.`id`, `seed_teams`.`created_at`, CAST(unixepoch('subsecond') * 1000 AS integer), `seed_teams`.`description`
|
||||
FROM `seed_teams` CROSS JOIN `target_organization`
|
||||
WHERE `target_organization`.`id` IS NOT NULL
|
||||
ON CONFLICT(`id`) DO UPDATE SET
|
||||
`name` = excluded.`name`,
|
||||
`organization_id` = excluded.`organization_id`,
|
||||
`updated_at` = excluded.`updated_at`,
|
||||
`description` = excluded.`description`;
|
||||
|
||||
INSERT INTO `team_member` (`id`, `team_id`, `user_id`, `created_at`) VALUES
|
||||
('seed-team-member-operations-priya', 'seed-team-operations', 'seed-user-priya', CAST(unixepoch('subsecond') * 1000 AS integer) - 1209600000),
|
||||
('seed-team-member-operations-kabir', 'seed-team-operations', 'seed-user-kabir', CAST(unixepoch('subsecond') * 1000 AS integer) - 1123200000),
|
||||
('seed-team-member-engineering-arjun', 'seed-team-engineering', 'seed-user-arjun', CAST(unixepoch('subsecond') * 1000 AS integer) - 1036800000),
|
||||
('seed-team-member-engineering-neha', 'seed-team-engineering', 'seed-user-neha', CAST(unixepoch('subsecond') * 1000 AS integer) - 950400000),
|
||||
('seed-team-member-engineering-meera', 'seed-team-engineering', 'seed-user-meera', CAST(unixepoch('subsecond') * 1000 AS integer) - 864000000),
|
||||
('seed-team-member-engineering-vikram', 'seed-team-engineering', 'seed-user-vikram', CAST(unixepoch('subsecond') * 1000 AS integer) - 777600000),
|
||||
('seed-team-member-field-rohan', 'seed-team-field-support', 'seed-user-rohan', CAST(unixepoch('subsecond') * 1000 AS integer) - 864000000),
|
||||
('seed-team-member-field-kabir', 'seed-team-field-support', 'seed-user-kabir', CAST(unixepoch('subsecond') * 1000 AS integer) - 777600000),
|
||||
('seed-team-member-people-isha', 'seed-team-people', 'seed-user-isha', CAST(unixepoch('subsecond') * 1000 AS integer) - 691200000),
|
||||
('seed-team-member-people-priya', 'seed-team-people', 'seed-user-priya', CAST(unixepoch('subsecond') * 1000 AS integer) - 604800000)
|
||||
ON CONFLICT(`id`) DO UPDATE SET
|
||||
`team_id` = excluded.`team_id`,
|
||||
`user_id` = excluded.`user_id`,
|
||||
`created_at` = excluded.`created_at`;
|
||||
@@ -0,0 +1,155 @@
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import { index, integer, real, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
import { organization, user } from "./auth-schema";
|
||||
|
||||
const timestamp = (name: string) =>
|
||||
integer(name, { mode: "timestamp_ms" })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull();
|
||||
|
||||
/** People enrolled in an organization's physical attendance system. */
|
||||
export const attendanceMember = sqliteTable(
|
||||
"attendance_member",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
memberCode: text("member_code").notNull(),
|
||||
firstName: text("first_name").notNull(),
|
||||
lastName: text("last_name").notNull(),
|
||||
email: text("email"),
|
||||
phone: text("phone"),
|
||||
department: text("department"),
|
||||
photoUrl: text("photo_url"),
|
||||
status: text("status").default("active").notNull(),
|
||||
registeredAt: timestamp("registered_at"),
|
||||
createdAt: timestamp("created_at"),
|
||||
updatedAt: timestamp("updated_at").$onUpdate(() => new Date()),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("attendance_member_org_code_uidx").on(table.organizationId, table.memberCode),
|
||||
uniqueIndex("attendance_member_org_email_uidx").on(table.organizationId, table.email),
|
||||
index("attendance_member_org_status_idx").on(table.organizationId, table.status),
|
||||
],
|
||||
);
|
||||
|
||||
/** CCTV or IP camera configured as an attendance event source. */
|
||||
export const camera = sqliteTable(
|
||||
"camera",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
location: text("location").notNull(),
|
||||
zone: text("zone"),
|
||||
streamUrl: text("stream_url"),
|
||||
status: text("status").default("offline").notNull(),
|
||||
lastSeenAt: integer("last_seen_at", { mode: "timestamp_ms" }),
|
||||
createdAt: timestamp("created_at"),
|
||||
updatedAt: timestamp("updated_at").$onUpdate(() => new Date()),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("camera_org_name_uidx").on(table.organizationId, table.name),
|
||||
index("camera_org_status_idx").on(table.organizationId, table.status),
|
||||
],
|
||||
);
|
||||
|
||||
/** Immutable check-in/check-out observations from cameras, cards, or manual entry. */
|
||||
export const attendanceEvent = sqliteTable(
|
||||
"attendance_event",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
memberId: text("member_id")
|
||||
.notNull()
|
||||
.references(() => attendanceMember.id),
|
||||
cameraId: text("camera_id").references(() => camera.id, { onDelete: "set null" }),
|
||||
direction: text("direction").notNull(),
|
||||
capturedAt: integer("captured_at", { mode: "timestamp_ms" }).notNull(),
|
||||
method: text("method").notNull(),
|
||||
confidence: real("confidence"),
|
||||
notes: text("notes"),
|
||||
createdByUserId: text("created_by_user_id").references(() => user.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("created_at"),
|
||||
},
|
||||
(table) => [
|
||||
index("attendance_event_org_captured_idx").on(table.organizationId, table.capturedAt),
|
||||
index("attendance_event_member_captured_idx").on(table.memberId, table.capturedAt),
|
||||
index("attendance_event_camera_captured_idx").on(table.cameraId, table.capturedAt),
|
||||
],
|
||||
);
|
||||
|
||||
/** Materialized daily counters for fast dashboard reads and reporting. */
|
||||
export const attendanceDailySummary = sqliteTable(
|
||||
"attendance_daily_summary",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
attendanceDate: text("attendance_date").notNull(),
|
||||
checkedInCount: integer("checked_in_count").default(0).notNull(),
|
||||
checkedOutCount: integer("checked_out_count").default(0).notNull(),
|
||||
presentCount: integer("present_count").default(0).notNull(),
|
||||
lateCount: integer("late_count").default(0).notNull(),
|
||||
absentCount: integer("absent_count").default(0).notNull(),
|
||||
peakOccupancy: integer("peak_occupancy").default(0).notNull(),
|
||||
createdAt: timestamp("created_at"),
|
||||
updatedAt: timestamp("updated_at").$onUpdate(() => new Date()),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("attendance_daily_summary_org_date_uidx").on(
|
||||
table.organizationId,
|
||||
table.attendanceDate,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const attendanceMemberRelations = relations(attendanceMember, ({ one, many }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [attendanceMember.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
events: many(attendanceEvent),
|
||||
}));
|
||||
|
||||
export const cameraRelations = relations(camera, ({ one, many }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [camera.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
events: many(attendanceEvent),
|
||||
}));
|
||||
|
||||
export const attendanceEventRelations = relations(attendanceEvent, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [attendanceEvent.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
member: one(attendanceMember, {
|
||||
fields: [attendanceEvent.memberId],
|
||||
references: [attendanceMember.id],
|
||||
}),
|
||||
camera: one(camera, {
|
||||
fields: [attendanceEvent.cameraId],
|
||||
references: [camera.id],
|
||||
}),
|
||||
createdBy: one(user, {
|
||||
fields: [attendanceEvent.createdByUserId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const attendanceDailySummaryRelations = relations(attendanceDailySummary, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [attendanceDailySummary.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
}));
|
||||
@@ -0,0 +1,279 @@
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const user = sqliteTable("user", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: integer("email_verified", { mode: "boolean" }).default(false).notNull(),
|
||||
image: text("image"),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
username: text("username").unique(),
|
||||
displayUsername: text("display_username"),
|
||||
twoFactorEnabled: integer("two_factor_enabled", { mode: "boolean" }).default(false),
|
||||
firstName: text("first_name").notNull(),
|
||||
lastName: text("last_name").notNull(),
|
||||
jobTitle: text("job_title"),
|
||||
phone: text("phone"),
|
||||
});
|
||||
|
||||
export const session = sqliteTable(
|
||||
"session",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
activeOrganizationId: text("active_organization_id"),
|
||||
activeTeamId: text("active_team_id"),
|
||||
},
|
||||
(table) => [index("session_userId_idx").on(table.userId)],
|
||||
);
|
||||
|
||||
export const account = sqliteTable(
|
||||
"account",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: integer("access_token_expires_at", {
|
||||
mode: "timestamp_ms",
|
||||
}),
|
||||
refreshTokenExpiresAt: integer("refresh_token_expires_at", {
|
||||
mode: "timestamp_ms",
|
||||
}),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index("account_userId_idx").on(table.userId)],
|
||||
);
|
||||
|
||||
export const verification = sqliteTable(
|
||||
"verification",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
||||
);
|
||||
|
||||
export const twoFactor = sqliteTable(
|
||||
"two_factor",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
secret: text("secret").notNull(),
|
||||
backupCodes: text("backup_codes").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
verified: integer("verified", { mode: "boolean" }).default(true),
|
||||
failedVerificationCount: integer("failed_verification_count").default(0),
|
||||
lockedUntil: integer("locked_until", { mode: "timestamp_ms" }),
|
||||
},
|
||||
(table) => [
|
||||
index("twoFactor_secret_idx").on(table.secret),
|
||||
index("twoFactor_userId_idx").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
export const organization = sqliteTable("organization", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
slug: text("slug").notNull().unique(),
|
||||
logo: text("logo"),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||
metadata: text("metadata"),
|
||||
});
|
||||
|
||||
export const team = sqliteTable(
|
||||
"team",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).$onUpdate(
|
||||
() => /* @__PURE__ */ new Date(),
|
||||
),
|
||||
description: text("description"),
|
||||
},
|
||||
(table) => [index("team_organizationId_idx").on(table.organizationId)],
|
||||
);
|
||||
|
||||
export const teamMember = sqliteTable(
|
||||
"team_member",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
teamId: text("team_id")
|
||||
.notNull()
|
||||
.references(() => team.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }),
|
||||
},
|
||||
(table) => [
|
||||
index("teamMember_teamId_idx").on(table.teamId),
|
||||
index("teamMember_userId_idx").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
export const member = sqliteTable(
|
||||
"member",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
role: text("role").default("member").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("member_organizationId_idx").on(table.organizationId),
|
||||
index("member_userId_idx").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
export const invitation = sqliteTable(
|
||||
"invitation",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
email: text("email").notNull(),
|
||||
role: text("role"),
|
||||
teamId: text("team_id"),
|
||||
status: text("status").default("pending").notNull(),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull(),
|
||||
inviterId: text("inviter_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [
|
||||
index("invitation_organizationId_idx").on(table.organizationId),
|
||||
index("invitation_email_idx").on(table.email),
|
||||
],
|
||||
);
|
||||
|
||||
export const userRelations = relations(user, ({ many }) => ({
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
twoFactors: many(twoFactor),
|
||||
teamMembers: many(teamMember),
|
||||
members: many(member),
|
||||
invitations: many(invitation),
|
||||
}));
|
||||
|
||||
export const sessionRelations = relations(session, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [session.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const accountRelations = relations(account, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [account.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [twoFactor.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const organizationRelations = relations(organization, ({ many }) => ({
|
||||
teams: many(team),
|
||||
members: many(member),
|
||||
invitations: many(invitation),
|
||||
}));
|
||||
|
||||
export const teamRelations = relations(team, ({ one, many }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [team.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
teamMembers: many(teamMember),
|
||||
}));
|
||||
|
||||
export const teamMemberRelations = relations(teamMember, ({ one }) => ({
|
||||
team: one(team, {
|
||||
fields: [teamMember.teamId],
|
||||
references: [team.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [teamMember.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const memberRelations = relations(member, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [member.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [member.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const invitationRelations = relations(invitation, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [invitation.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [invitation.inviterId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
@@ -0,0 +1,9 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { drizzle } from "drizzle-orm/d1";
|
||||
import * as schema from "./schema";
|
||||
|
||||
/** Typed Drizzle client backed by the Worker's `DB` D1 binding. */
|
||||
export const db = drizzle(env.DB, { schema });
|
||||
|
||||
export type Database = typeof db;
|
||||
export { schema };
|
||||
@@ -0,0 +1,30 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
import { user } from "./auth-schema";
|
||||
|
||||
/** Metadata for member gallery images. Image bytes are stored in the bound R2 bucket. */
|
||||
export const memberGalleryPhoto = sqliteTable(
|
||||
"member_gallery_photo",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
slot: integer("slot").notNull(),
|
||||
objectKey: text("object_key").notNull(),
|
||||
originalName: text("original_name").notNull(),
|
||||
mimeType: text("mime_type").notNull(),
|
||||
size: integer("size").notNull(),
|
||||
uploadedByUserId: text("uploaded_by_user_id").references(() => user.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("member_gallery_photo_user_slot_uidx").on(table.userId, table.slot),
|
||||
uniqueIndex("member_gallery_photo_object_key_uidx").on(table.objectKey),
|
||||
index("member_gallery_photo_user_created_idx").on(table.userId, table.createdAt),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./auth-schema";
|
||||
export * from "./attendance-schema";
|
||||
export * from "./member-gallery-schema";
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Button, Heading, Text } from "@react-email/components";
|
||||
import {
|
||||
EmailLayout,
|
||||
titleStyle,
|
||||
bodyStyle,
|
||||
buttonStyle,
|
||||
mutedStyle,
|
||||
} from "./components/EmailLayout";
|
||||
|
||||
export interface MagicLinkEmailProps {
|
||||
appName: string;
|
||||
/** Full magic-link verify URL supplied by Better Auth. */
|
||||
magicLinkUrl: string;
|
||||
/** How long the link is valid, in minutes (for the copy). */
|
||||
validForMinutes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwordless sign-in link. Reuses the same renderer/transport as the
|
||||
* organization invitation flow.
|
||||
*/
|
||||
export function MagicLinkEmail({
|
||||
appName,
|
||||
magicLinkUrl,
|
||||
validForMinutes = 15,
|
||||
}: MagicLinkEmailProps) {
|
||||
return (
|
||||
<EmailLayout preview={`Your ${appName} sign-in link`} appName={appName}>
|
||||
<Heading style={titleStyle}>Sign in to {appName}</Heading>
|
||||
<Text style={bodyStyle}>
|
||||
Click the button below to sign in. This link is valid for{" "}
|
||||
<strong>{validForMinutes} minutes</strong> and can only be used once.
|
||||
</Text>
|
||||
|
||||
<Button href={magicLinkUrl} style={buttonStyle}>
|
||||
Sign in
|
||||
</Button>
|
||||
|
||||
<Text style={mutedStyle}>
|
||||
If you didn't request this link, you can safely ignore this email.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Button, Heading, Text } from "@react-email/components";
|
||||
import {
|
||||
EmailLayout,
|
||||
titleStyle,
|
||||
bodyStyle,
|
||||
buttonStyle,
|
||||
mutedStyle,
|
||||
} from "./components/EmailLayout";
|
||||
import React from "react";
|
||||
|
||||
export interface OrganizationInvitationEmailProps {
|
||||
appName: string;
|
||||
organizationName: string;
|
||||
inviterName: string;
|
||||
inviterEmail: string;
|
||||
/** Better Auth role the invitee will receive (owner/admin/member). */
|
||||
role: string;
|
||||
/** Full URL to the web app invitation page, e.g. https://app.example/invitations/<id>. */
|
||||
invitationUrl: string;
|
||||
/** When the invitation link stops working. */
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invitation to join an organization. Rendered on the Worker and delivered
|
||||
* by `sendEmail` — Better Auth never sends this itself.
|
||||
*/
|
||||
export function OrganizationInvitationEmail({
|
||||
appName,
|
||||
organizationName,
|
||||
inviterName,
|
||||
inviterEmail,
|
||||
role,
|
||||
invitationUrl,
|
||||
expiresAt,
|
||||
}: OrganizationInvitationEmailProps) {
|
||||
const expiry = expiresAt.toLocaleString("en-US", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
|
||||
return (
|
||||
<EmailLayout
|
||||
preview={`${inviterName} invited you to join ${organizationName} on ${appName}`}
|
||||
appName={appName}
|
||||
>
|
||||
<Heading style={titleStyle}>You're invited to {organizationName}</Heading>
|
||||
<Text style={bodyStyle}>
|
||||
<strong>{inviterName}</strong> ({inviterEmail}) invited you to join{" "}
|
||||
<strong>{organizationName}</strong> on {appName} as <strong>{role}</strong>.
|
||||
</Text>
|
||||
<Text style={bodyStyle}>
|
||||
This invitation expires on <strong>{expiry}</strong>. If you already have an account,
|
||||
you'll be signed in and taken straight to the invitation.
|
||||
</Text>
|
||||
|
||||
<Button href={invitationUrl} style={buttonStyle}>
|
||||
Accept invitation
|
||||
</Button>
|
||||
|
||||
<Text style={mutedStyle}>
|
||||
If the button doesn't work, copy and paste this link into your browser:{" "}
|
||||
<span style={linkStyle}>{invitationUrl}</span>
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const linkStyle: React.CSSProperties = {
|
||||
wordBreak: "break-all",
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Body, Container, Head, Hr, Html, Preview, Section, Text } from "@react-email/components";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const brand = "#4338ca";
|
||||
const ink = "#18181b";
|
||||
const muted = "#71717a";
|
||||
const border = "#e4e4e7";
|
||||
const surface = "#fafafa";
|
||||
|
||||
interface EmailLayoutProps {
|
||||
/** Short preview line shown in inbox lists. */
|
||||
preview: string;
|
||||
/** App display name shown in the header/footer. */
|
||||
appName: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared shell for transactional email. Neutral, system-font, inline-styled
|
||||
* (no Tailwind) so the Worker bundle stays lean and rendering is fast.
|
||||
*/
|
||||
export function EmailLayout({ preview, appName, children }: EmailLayoutProps) {
|
||||
return (
|
||||
<Html lang="en" dir="ltr">
|
||||
<Head />
|
||||
<Preview>{preview}</Preview>
|
||||
<Body style={layoutBodyStyle}>
|
||||
<Container style={containerStyle}>
|
||||
<Section style={headerStyle}>
|
||||
<Text style={brandStyle}>{appName}</Text>
|
||||
</Section>
|
||||
|
||||
<Section style={cardStyle}>{children}</Section>
|
||||
|
||||
<Hr style={hrStyle} />
|
||||
<Text style={footerStyle}>
|
||||
You received this email because you have an account or were invited to {appName}. If
|
||||
this wasn't you, you can safely ignore it.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
const layoutBodyStyle: React.CSSProperties = {
|
||||
backgroundColor: surface,
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
||||
margin: 0,
|
||||
padding: "24px 0",
|
||||
};
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
maxWidth: 480,
|
||||
margin: "0 auto",
|
||||
padding: "0 16px",
|
||||
};
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
padding: "8px 0 16px",
|
||||
};
|
||||
|
||||
const brandStyle: React.CSSProperties = {
|
||||
color: brand,
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "-0.01em",
|
||||
margin: 0,
|
||||
};
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
backgroundColor: "#ffffff",
|
||||
border: `1px solid ${border}`,
|
||||
borderRadius: 12,
|
||||
padding: "28px 28px 24px",
|
||||
};
|
||||
|
||||
const hrStyle: React.CSSProperties = {
|
||||
border: "none",
|
||||
borderTop: `1px solid ${border}`,
|
||||
margin: "24px 0 16px",
|
||||
};
|
||||
|
||||
const footerStyle: React.CSSProperties = {
|
||||
color: muted,
|
||||
fontSize: 12,
|
||||
lineHeight: "18px",
|
||||
margin: 0,
|
||||
textAlign: "center",
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared content styles used by the email templates (heading, body text,
|
||||
* primary button, muted helper text).
|
||||
*/
|
||||
export const titleStyle: React.CSSProperties = {
|
||||
color: ink,
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "-0.01em",
|
||||
margin: "0 0 12px",
|
||||
};
|
||||
|
||||
export const bodyStyle: React.CSSProperties = {
|
||||
color: ink,
|
||||
fontSize: 14,
|
||||
lineHeight: "22px",
|
||||
margin: "0 0 12px",
|
||||
};
|
||||
|
||||
export const buttonStyle: React.CSSProperties = {
|
||||
backgroundColor: brand,
|
||||
borderRadius: 8,
|
||||
color: "#ffffff",
|
||||
display: "inline-block",
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
margin: "16px 0",
|
||||
padding: "10px 18px",
|
||||
textDecoration: "none",
|
||||
};
|
||||
|
||||
export const mutedStyle: React.CSSProperties = {
|
||||
color: muted,
|
||||
fontSize: 12,
|
||||
lineHeight: "18px",
|
||||
margin: "16px 0 0",
|
||||
};
|
||||
|
||||
export const emailTokens = { brand, ink, muted, border } as const;
|
||||
Vendored
+30
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Type augmentation for bindings not present in the generated
|
||||
* `worker-configuration.d.ts` (secrets set via `wrangler secret put`
|
||||
* or `.dev.vars`).
|
||||
*
|
||||
* Regenerate the base types with `pnpm run cf-typegen` after changing
|
||||
* bindings in `wrangler.jsonc`.
|
||||
*/
|
||||
declare namespace Cloudflare {
|
||||
interface Env {
|
||||
/** Better Auth encryption/hashing secret — must be ≥32 chars. */
|
||||
BETTER_AUTH_SECRET: string;
|
||||
/** Public base URL of the auth server (e.g. https://api.example.com). */
|
||||
BETTER_AUTH_URL: string;
|
||||
/** Public base URL of the web app — used to build invitation and magic-link URLs. */
|
||||
WEB_APP_URL: string;
|
||||
/** Sender for transactional email, e.g. "Attendance <[email protected]>". */
|
||||
EMAIL_FROM: string;
|
||||
/** Optional reply-to address for transactional email. */
|
||||
EMAIL_REPLY_TO?: string;
|
||||
/** Resend API key (secret) — when set, email is delivered via Resend REST API. */
|
||||
RESEND_API_KEY?: string;
|
||||
/** Comma-separated list of extra origins allowed by CORS / trustedOrigins. */
|
||||
TRUSTED_ORIGINS?: string;
|
||||
GITHUB_CLIENT_ID?: string;
|
||||
GITHUB_CLIENT_SECRET?: string;
|
||||
GOOGLE_CLIENT_ID?: string;
|
||||
GOOGLE_CLIENT_SECRET?: string;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { Elysia, t } from "elysia";
|
||||
import { CloudflareAdapter } from "elysia/adapter/cloudflare-worker";
|
||||
import { cors } from "@elysiajs/cors";
|
||||
import { env } from "cloudflare:workers";
|
||||
import { db } from "./db";
|
||||
import { member, team, teamMember, user as userTable } from "./db/auth-schema";
|
||||
import { memberGalleryPhoto } from "./db/member-gallery-schema";
|
||||
import { trustedOrigins } from "./lib/auth/auth";
|
||||
import { betterAuthPlugin } from "./micro/better-auth-plugin";
|
||||
|
||||
const PROFILE_IMAGE_MAX_SOURCE_LENGTH = 700_000;
|
||||
const MEMBER_GALLERY_MAX_PHOTOS = 100;
|
||||
const MEMBER_GALLERY_MAX_UPLOAD_BYTES = 4 * 1024 * 1024;
|
||||
const profileImageDataUrlPattern = /^data:image\/(?:jpeg|png|webp);base64,[a-z\d+/=]+$/i;
|
||||
const supportedGalleryImageTypes = new Set(["image/jpeg", "image/png", "image/webp"]);
|
||||
|
||||
function isValidProfileImage(image: string) {
|
||||
if (image.length > PROFILE_IMAGE_MAX_SOURCE_LENGTH) return false;
|
||||
if (profileImageDataUrlPattern.test(image)) return true;
|
||||
|
||||
try {
|
||||
const imageUrl = new URL(image);
|
||||
return imageUrl.protocol === "https:" || imageUrl.protocol === "http:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getMemberGalleryAccess(
|
||||
organizationId: string,
|
||||
memberId: string,
|
||||
viewerUserId: string,
|
||||
) {
|
||||
const [viewerMembership] = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, viewerUserId)))
|
||||
.limit(1);
|
||||
|
||||
if (!viewerMembership) return { error: "viewer" as const };
|
||||
|
||||
const [targetMembership] = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(and(eq(member.id, memberId), eq(member.organizationId, organizationId)))
|
||||
.limit(1);
|
||||
|
||||
if (!targetMembership) return { error: "target" as const };
|
||||
|
||||
return {
|
||||
viewerRole: viewerMembership.role,
|
||||
targetUserId: targetMembership.userId,
|
||||
canEdit:
|
||||
viewerMembership.role === "owner" ||
|
||||
viewerMembership.role === "admin" ||
|
||||
targetMembership.userId === viewerUserId,
|
||||
};
|
||||
}
|
||||
|
||||
function galleryAccessError(
|
||||
access: Awaited<ReturnType<typeof getMemberGalleryAccess>>,
|
||||
status: (code: number, response: { error: string }) => unknown,
|
||||
) {
|
||||
if (!("error" in access)) return null;
|
||||
if (access.error === "viewer") {
|
||||
return status(403, { error: "You do not belong to this organization." });
|
||||
}
|
||||
return status(404, { error: "Organization member not found." });
|
||||
}
|
||||
|
||||
const app = new Elysia({
|
||||
adapter: CloudflareAdapter,
|
||||
})
|
||||
.use(
|
||||
cors({
|
||||
origin: trustedOrigins,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
credentials: true,
|
||||
allowedHeaders: ["Content-Type", "Authorization"],
|
||||
exposeHeaders: ["set-cookie"],
|
||||
}),
|
||||
)
|
||||
.use(betterAuthPlugin)
|
||||
.get("/", () => ({ status: "ok" }))
|
||||
.get("/api/health", () => ({ status: "ok", ts: Date.now() }))
|
||||
.get("/api/me", ({ user, session }) => ({ user, session }), { auth: true })
|
||||
.get(
|
||||
"/api/organizations/:organizationId/teams",
|
||||
async ({ params, status, user }) => {
|
||||
const [viewerMembership] = await db
|
||||
.select({ id: member.id })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, params.organizationId), eq(member.userId, user.id)))
|
||||
.limit(1);
|
||||
|
||||
if (!viewerMembership) {
|
||||
return status(403, { error: "You do not belong to this organization." });
|
||||
}
|
||||
|
||||
const teams = await db
|
||||
.select({
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
description: team.description,
|
||||
organizationId: team.organizationId,
|
||||
createdAt: team.createdAt,
|
||||
updatedAt: team.updatedAt,
|
||||
})
|
||||
.from(team)
|
||||
.where(eq(team.organizationId, params.organizationId));
|
||||
|
||||
const memberships = await db
|
||||
.select({
|
||||
id: teamMember.id,
|
||||
teamId: teamMember.teamId,
|
||||
userId: teamMember.userId,
|
||||
createdAt: teamMember.createdAt,
|
||||
})
|
||||
.from(teamMember)
|
||||
.innerJoin(team, eq(team.id, teamMember.teamId))
|
||||
.where(eq(team.organizationId, params.organizationId));
|
||||
|
||||
return { teams, memberships };
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
params: t.Object({
|
||||
organizationId: t.String({ minLength: 1, maxLength: 128 }),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/api/organizations/:organizationId/members/:memberId/profile",
|
||||
async ({ body, params, status, user }) => {
|
||||
const [viewerMembership] = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, params.organizationId), eq(member.userId, user.id)))
|
||||
.limit(1);
|
||||
|
||||
if (!viewerMembership) {
|
||||
return status(403, { error: "You do not belong to this organization." });
|
||||
}
|
||||
|
||||
const [targetMembership] = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(
|
||||
and(eq(member.id, params.memberId), eq(member.organizationId, params.organizationId)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!targetMembership) {
|
||||
return status(404, { error: "Organization member not found." });
|
||||
}
|
||||
|
||||
const canManageMembers =
|
||||
viewerMembership.role === "owner" || viewerMembership.role === "admin";
|
||||
if (!canManageMembers && targetMembership.userId !== user.id) {
|
||||
return status(403, { error: "You cannot edit this member's profile." });
|
||||
}
|
||||
|
||||
const firstName = body.firstName.trim();
|
||||
const lastName = body.lastName.trim();
|
||||
const jobTitle = body.jobTitle.trim();
|
||||
const phone = body.phone.trim();
|
||||
const image = body.image.trim();
|
||||
|
||||
if (!firstName || !lastName) {
|
||||
return status(422, { error: "First and last name are required." });
|
||||
}
|
||||
|
||||
if (image && !isValidProfileImage(image)) {
|
||||
return status(422, {
|
||||
error: "Choose a valid JPG, PNG, or WebP image, or enter an HTTP or HTTPS URL.",
|
||||
});
|
||||
}
|
||||
|
||||
const name = `${firstName} ${lastName}`;
|
||||
await db
|
||||
.update(userTable)
|
||||
.set({
|
||||
firstName,
|
||||
lastName,
|
||||
name,
|
||||
jobTitle: jobTitle || null,
|
||||
phone: phone || null,
|
||||
image: image || null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(userTable.id, targetMembership.userId));
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: targetMembership.userId,
|
||||
firstName,
|
||||
lastName,
|
||||
name,
|
||||
jobTitle: jobTitle || null,
|
||||
phone: phone || null,
|
||||
image: image || null,
|
||||
},
|
||||
};
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
params: t.Object({
|
||||
organizationId: t.String({ minLength: 1, maxLength: 128 }),
|
||||
memberId: t.String({ minLength: 1, maxLength: 128 }),
|
||||
}),
|
||||
body: t.Object({
|
||||
firstName: t.String({ minLength: 1, maxLength: 80 }),
|
||||
lastName: t.String({ minLength: 1, maxLength: 80 }),
|
||||
jobTitle: t.String({ maxLength: 80 }),
|
||||
phone: t.String({ maxLength: 30 }),
|
||||
image: t.String({ maxLength: PROFILE_IMAGE_MAX_SOURCE_LENGTH }),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/api/organizations/:organizationId/members/:memberId/gallery",
|
||||
async ({ params, status, user }) => {
|
||||
const access = await getMemberGalleryAccess(
|
||||
params.organizationId,
|
||||
params.memberId,
|
||||
user.id,
|
||||
);
|
||||
const accessError = galleryAccessError(access, status);
|
||||
if (accessError) return accessError;
|
||||
if ("error" in access) return;
|
||||
|
||||
const photos = await db
|
||||
.select({
|
||||
id: memberGalleryPhoto.id,
|
||||
originalName: memberGalleryPhoto.originalName,
|
||||
mimeType: memberGalleryPhoto.mimeType,
|
||||
size: memberGalleryPhoto.size,
|
||||
createdAt: memberGalleryPhoto.createdAt,
|
||||
})
|
||||
.from(memberGalleryPhoto)
|
||||
.where(eq(memberGalleryPhoto.userId, access.targetUserId))
|
||||
.orderBy(desc(memberGalleryPhoto.createdAt));
|
||||
|
||||
return {
|
||||
photos: photos.map((photo) => ({
|
||||
...photo,
|
||||
createdAt: photo.createdAt.getTime(),
|
||||
})),
|
||||
total: photos.length,
|
||||
maxPhotos: MEMBER_GALLERY_MAX_PHOTOS,
|
||||
canEdit: access.canEdit,
|
||||
};
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
params: t.Object({
|
||||
organizationId: t.String({ minLength: 1, maxLength: 128 }),
|
||||
memberId: t.String({ minLength: 1, maxLength: 128 }),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/api/organizations/:organizationId/members/:memberId/gallery",
|
||||
async ({ body, params, status, user }) => {
|
||||
const access = await getMemberGalleryAccess(
|
||||
params.organizationId,
|
||||
params.memberId,
|
||||
user.id,
|
||||
);
|
||||
const accessError = galleryAccessError(access, status);
|
||||
if (accessError) return accessError;
|
||||
if ("error" in access) return;
|
||||
if (!access.canEdit) {
|
||||
return status(403, { error: "You cannot add photos to this member's gallery." });
|
||||
}
|
||||
|
||||
const file = body.file;
|
||||
if (!supportedGalleryImageTypes.has(file.type)) {
|
||||
return status(422, { error: "Choose a JPG, PNG, or WebP image." });
|
||||
}
|
||||
if (file.size > MEMBER_GALLERY_MAX_UPLOAD_BYTES) {
|
||||
return status(422, { error: "Gallery photos must be 4 MB or smaller after processing." });
|
||||
}
|
||||
|
||||
const occupiedSlots = await db
|
||||
.select({ slot: memberGalleryPhoto.slot })
|
||||
.from(memberGalleryPhoto)
|
||||
.where(eq(memberGalleryPhoto.userId, access.targetUserId));
|
||||
const occupied = new Set(occupiedSlots.map((photo) => photo.slot));
|
||||
let slot = 1;
|
||||
while (slot <= MEMBER_GALLERY_MAX_PHOTOS && occupied.has(slot)) slot += 1;
|
||||
|
||||
if (slot > MEMBER_GALLERY_MAX_PHOTOS) {
|
||||
return status(409, {
|
||||
error: `Each member can store up to ${MEMBER_GALLERY_MAX_PHOTOS} gallery photos.`,
|
||||
});
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const objectKey = `members/${access.targetUserId}/gallery/${id}`;
|
||||
await env.MEMBER_GALLERY.put(objectKey, file, {
|
||||
httpMetadata: { contentType: file.type },
|
||||
});
|
||||
|
||||
try {
|
||||
await db.insert(memberGalleryPhoto).values({
|
||||
id,
|
||||
userId: access.targetUserId,
|
||||
slot,
|
||||
objectKey,
|
||||
originalName: file.name.slice(0, 255) || "photo",
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
uploadedByUserId: user.id,
|
||||
});
|
||||
} catch (error) {
|
||||
await env.MEMBER_GALLERY.delete(objectKey);
|
||||
if (error instanceof Error && /unique/i.test(error.message)) {
|
||||
return status(409, { error: "The gallery changed while uploading. Please try again." });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return status(201, {
|
||||
photo: {
|
||||
id,
|
||||
originalName: file.name.slice(0, 255) || "photo",
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
maxPhotos: MEMBER_GALLERY_MAX_PHOTOS,
|
||||
});
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
params: t.Object({
|
||||
organizationId: t.String({ minLength: 1, maxLength: 128 }),
|
||||
memberId: t.String({ minLength: 1, maxLength: 128 }),
|
||||
}),
|
||||
body: t.Object({
|
||||
file: t.File({
|
||||
type: ["image/jpeg", "image/png", "image/webp"],
|
||||
maxSize: MEMBER_GALLERY_MAX_UPLOAD_BYTES,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/api/organizations/:organizationId/members/:memberId/gallery/:photoId/content",
|
||||
async ({ params, status, user }) => {
|
||||
const access = await getMemberGalleryAccess(
|
||||
params.organizationId,
|
||||
params.memberId,
|
||||
user.id,
|
||||
);
|
||||
const accessError = galleryAccessError(access, status);
|
||||
if (accessError) return accessError;
|
||||
if ("error" in access) return;
|
||||
|
||||
const [photo] = await db
|
||||
.select({ objectKey: memberGalleryPhoto.objectKey })
|
||||
.from(memberGalleryPhoto)
|
||||
.where(
|
||||
and(
|
||||
eq(memberGalleryPhoto.id, params.photoId),
|
||||
eq(memberGalleryPhoto.userId, access.targetUserId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!photo) return status(404, { error: "Gallery photo not found." });
|
||||
|
||||
const object = await env.MEMBER_GALLERY.get(photo.objectKey);
|
||||
if (!object || !("body" in object)) {
|
||||
return status(404, { error: "Gallery photo file not found." });
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
object.writeHttpMetadata(headers);
|
||||
headers.set("etag", object.httpEtag);
|
||||
headers.set("Cache-Control", "private, max-age=3600");
|
||||
return new Response(object.body, { headers });
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
params: t.Object({
|
||||
organizationId: t.String({ minLength: 1, maxLength: 128 }),
|
||||
memberId: t.String({ minLength: 1, maxLength: 128 }),
|
||||
photoId: t.String({ minLength: 1, maxLength: 128 }),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/api/organizations/:organizationId/members/:memberId/gallery/:photoId",
|
||||
async ({ params, status, user }) => {
|
||||
const access = await getMemberGalleryAccess(
|
||||
params.organizationId,
|
||||
params.memberId,
|
||||
user.id,
|
||||
);
|
||||
const accessError = galleryAccessError(access, status);
|
||||
if (accessError) return accessError;
|
||||
if ("error" in access) return;
|
||||
if (!access.canEdit) {
|
||||
return status(403, { error: "You cannot remove photos from this member's gallery." });
|
||||
}
|
||||
|
||||
const [photo] = await db
|
||||
.select({ id: memberGalleryPhoto.id, objectKey: memberGalleryPhoto.objectKey })
|
||||
.from(memberGalleryPhoto)
|
||||
.where(
|
||||
and(
|
||||
eq(memberGalleryPhoto.id, params.photoId),
|
||||
eq(memberGalleryPhoto.userId, access.targetUserId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!photo) return status(404, { error: "Gallery photo not found." });
|
||||
|
||||
await env.MEMBER_GALLERY.delete(photo.objectKey);
|
||||
await db.delete(memberGalleryPhoto).where(eq(memberGalleryPhoto.id, photo.id));
|
||||
return { deleted: true };
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
params: t.Object({
|
||||
organizationId: t.String({ minLength: 1, maxLength: 128 }),
|
||||
memberId: t.String({ minLength: 1, maxLength: 128 }),
|
||||
photoId: t.String({ minLength: 1, maxLength: 128 }),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.compile();
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { BetterAuthOptions } from "better-auth";
|
||||
import { magicLink, organization, twoFactor, username } from "better-auth/plugins";
|
||||
import { env } from "cloudflare:workers";
|
||||
import { sendInvitationEmail } from "./invitation-email";
|
||||
import { sendMagicLink } from "./magic-link-email";
|
||||
|
||||
/**
|
||||
* Shared Better Auth configuration.
|
||||
*
|
||||
* The `database` is injected by the runtime config (`auth.ts`) and the
|
||||
* CLI-generation config (`auth.generate.ts`). Everything else lives here so
|
||||
* the schema stays in sync between the two.
|
||||
*
|
||||
* NOTE: the CLI stubs `cloudflare:workers` when loading the config, so
|
||||
* `env.*` is empty at generate time — the config must not crash on missing
|
||||
* bindings (email callbacks only run at request time, never at load time).
|
||||
*/
|
||||
|
||||
export const baseURL = env.BETTER_AUTH_URL || "http://localhost:8787";
|
||||
|
||||
/** Origins allowed by CORS and by Better Auth's trustedOrigins check. */
|
||||
export const trustedOrigins = [
|
||||
baseURL,
|
||||
...(env.TRUSTED_ORIGINS?.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean) ?? []),
|
||||
].filter((origin): origin is string => Boolean(origin));
|
||||
|
||||
/** True when the auth server runs locally (Vite dev port varies). */
|
||||
export const isLocalDev =
|
||||
baseURL.startsWith("http://localhost") || baseURL.startsWith("http://127.0.0.1");
|
||||
|
||||
const socialProviders: NonNullable<BetterAuthOptions["socialProviders"]> = {};
|
||||
if (env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET) {
|
||||
socialProviders.github = {
|
||||
clientId: env.GITHUB_CLIENT_ID,
|
||||
clientSecret: env.GITHUB_CLIENT_SECRET,
|
||||
};
|
||||
}
|
||||
|
||||
if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
||||
socialProviders.google = {
|
||||
clientId: env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
||||
};
|
||||
}
|
||||
|
||||
type TeamCreatedHandler = (teamId: string, userId: string) => Promise<void>;
|
||||
|
||||
export function buildAuthOptions(
|
||||
database: BetterAuthOptions["database"],
|
||||
onTeamCreated?: TeamCreatedHandler,
|
||||
): BetterAuthOptions {
|
||||
return {
|
||||
appName: "attendance",
|
||||
baseURL,
|
||||
secret: env.BETTER_AUTH_SECRET || "dev-secret-change-me-0123456789abcdef",
|
||||
database,
|
||||
trustedOrigins,
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
minPasswordLength: 8,
|
||||
},
|
||||
user: {
|
||||
additionalFields: {
|
||||
firstName: {
|
||||
type: "string",
|
||||
},
|
||||
lastName: {
|
||||
type: "string",
|
||||
},
|
||||
jobTitle: {
|
||||
type: "string",
|
||||
required: false,
|
||||
},
|
||||
phone: {
|
||||
type: "string",
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
socialProviders,
|
||||
plugins: [
|
||||
username({
|
||||
minUsernameLength: 3,
|
||||
maxUsernameLength: 30,
|
||||
}),
|
||||
magicLink({
|
||||
sendMagicLink,
|
||||
}),
|
||||
twoFactor({
|
||||
issuer: "attendance",
|
||||
}),
|
||||
organization({
|
||||
// Organization policy:
|
||||
// - any authenticated user may create up to 10 organizations,
|
||||
// - creators become owner,
|
||||
// - 100 members / 100 pending invitations per organization,
|
||||
// - invitations expire after 48h,
|
||||
// - re-inviting an address cancels the previous pending invite,
|
||||
// - deletion stays enabled for owners.
|
||||
allowUserToCreateOrganization: true,
|
||||
organizationLimit: 10,
|
||||
creatorRole: "owner",
|
||||
membershipLimit: 100,
|
||||
invitationLimit: 100,
|
||||
invitationExpiresIn: 60 * 60 * 48,
|
||||
cancelPendingInvitationsOnReInvite: true,
|
||||
disableOrganizationDeletion: false,
|
||||
teams: {
|
||||
enabled: true,
|
||||
defaultTeam: { enabled: false },
|
||||
maximumTeams: 25,
|
||||
maximumMembersPerTeam: 100,
|
||||
allowRemovingAllTeams: true,
|
||||
},
|
||||
schema: {
|
||||
team: {
|
||||
additionalFields: {
|
||||
description: {
|
||||
type: "string",
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
organizationHooks: onTeamCreated
|
||||
? {
|
||||
afterCreateTeam: async ({ team, user }) => {
|
||||
if (user) await onTeamCreated(team.id, user.id);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
dynamicAccessControl: { enabled: false },
|
||||
sendInvitationEmail,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { drizzle } from "drizzle-orm/d1";
|
||||
import * as schema from "../../db/auth-schema";
|
||||
import { buildAuthOptions } from "./auth-options";
|
||||
|
||||
/**
|
||||
* CLI-ONLY auth config used for `npx auth generate` (and nothing else).
|
||||
*
|
||||
* The Better Auth CLI runs in Node, where the Worker's D1 binding is not
|
||||
* available. Schema generation never executes a query, so an inert typed D1
|
||||
* client is sufficient for Drizzle to expose its adapter metadata.
|
||||
*
|
||||
* Generate with:
|
||||
* pnpm run auth:schema
|
||||
*
|
||||
* Keep the plugins and schema-affecting options in `auth-options.ts` in sync
|
||||
* with `auth.ts`.
|
||||
*/
|
||||
|
||||
const generationDb = drizzle({} as D1Database, { schema });
|
||||
|
||||
export const auth = betterAuth(
|
||||
buildAuthOptions(
|
||||
drizzleAdapter(generationDb, {
|
||||
provider: "sqlite",
|
||||
schema,
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,40 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "../../db";
|
||||
import * as schema from "../../db/auth-schema";
|
||||
import { buildAuthOptions, baseURL, trustedOrigins } from "./auth-options";
|
||||
|
||||
/**
|
||||
* Better Auth instance (runtime).
|
||||
*
|
||||
* Drizzle owns the D1 connection and Better Auth uses its SQLite adapter.
|
||||
* Passing the generated schema keeps auth queries fully typed and ensures
|
||||
* plugin fields stay aligned with Drizzle migrations.
|
||||
*/
|
||||
export { baseURL, trustedOrigins };
|
||||
|
||||
export const auth = betterAuth(
|
||||
buildAuthOptions(
|
||||
drizzleAdapter(db, {
|
||||
provider: "sqlite",
|
||||
schema,
|
||||
}),
|
||||
async (teamId, userId) => {
|
||||
const [existingMembership] = await db
|
||||
.select({ id: schema.teamMember.id })
|
||||
.from(schema.teamMember)
|
||||
.where(and(eq(schema.teamMember.teamId, teamId), eq(schema.teamMember.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!existingMembership) {
|
||||
await db.insert(schema.teamMember).values({
|
||||
id: crypto.randomUUID(),
|
||||
teamId,
|
||||
userId,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,61 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { OrganizationInvitationEmail } from "../../emails/OrganizationInvitationEmail";
|
||||
import { renderEmail } from "../email/render-email";
|
||||
import { sendEmail } from "../email/send-email";
|
||||
|
||||
/** Display name used in transactional email (keep in sync with the web app). */
|
||||
const APP_NAME = "Attendance";
|
||||
|
||||
/** Base URL of the web app (trailing slash stripped). */
|
||||
export function webAppUrl(): string {
|
||||
return (env.WEB_APP_URL || "http://localhost:5173").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the invitation acceptance URL for the web app.
|
||||
*
|
||||
* The route is intentionally an opaque, signed-looking path that only
|
||||
* resolves for the invited user's session — Better Auth validates ownership.
|
||||
*/
|
||||
export function invitationUrl(invitationId: string): string {
|
||||
return `${webAppUrl()}/invitations/${encodeURIComponent(invitationId)}`;
|
||||
}
|
||||
|
||||
interface InvitationEmailData {
|
||||
id: string;
|
||||
role: string;
|
||||
email: string;
|
||||
organization: { name: string };
|
||||
invitation: { expiresAt: Date };
|
||||
inviter: { user: { name: string; email: string } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Better Auth `organization.sendInvitationEmail` callback.
|
||||
*
|
||||
* Renders the React Email template and delivers it through `sendEmail`
|
||||
* (Resend in production, console in local dev). Errors propagate so the
|
||||
* inviter sees a clear failure if delivery fails — an invitation is never
|
||||
* silently created without an email.
|
||||
*/
|
||||
export async function sendInvitationEmail(data: InvitationEmailData): Promise<void> {
|
||||
const { html, text } = await renderEmail(
|
||||
OrganizationInvitationEmail({
|
||||
appName: APP_NAME,
|
||||
organizationName: data.organization.name,
|
||||
inviterName: data.inviter.user.name,
|
||||
inviterEmail: data.inviter.user.email,
|
||||
role: data.role,
|
||||
invitationUrl: invitationUrl(data.id),
|
||||
expiresAt: data.invitation.expiresAt,
|
||||
}),
|
||||
);
|
||||
|
||||
await sendEmail({
|
||||
to: data.email,
|
||||
subject: `You're invited to join ${data.organization.name}`,
|
||||
html,
|
||||
text,
|
||||
idempotencyKey: `organization-invitation/${data.id}`,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MagicLinkEmail } from "../../emails/MagicLinkEmail";
|
||||
import { renderEmail } from "../email/render-email";
|
||||
import { sendEmail } from "../email/send-email";
|
||||
|
||||
const APP_NAME = "Attendance";
|
||||
|
||||
interface MagicLinkData {
|
||||
email: string;
|
||||
/** Full verify URL supplied by Better Auth. */
|
||||
url: string;
|
||||
/** Opaque token — never logged. */
|
||||
token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Better Auth `magicLink.sendMagicLink` callback.
|
||||
*
|
||||
* Replaces the previous console-only TODO with the same render + transport
|
||||
* pipeline used for organization invitations.
|
||||
*/
|
||||
export async function sendMagicLink(data: MagicLinkData): Promise<void> {
|
||||
const { html, text } = await renderEmail(
|
||||
MagicLinkEmail({
|
||||
appName: APP_NAME,
|
||||
magicLinkUrl: data.url,
|
||||
}),
|
||||
);
|
||||
|
||||
await sendEmail({
|
||||
to: data.email,
|
||||
subject: `Sign in to ${APP_NAME}`,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { OrganizationInvitationEmail } from "../../emails/OrganizationInvitationEmail";
|
||||
import { MagicLinkEmail } from "../../emails/MagicLinkEmail";
|
||||
import { renderEmail } from "./render-email";
|
||||
|
||||
const INVITATION_URL = "https://app.example/invitations/abc123";
|
||||
const EXPIRES = new Date("2026-08-10T00:00:00Z");
|
||||
|
||||
describe("renderEmail", () => {
|
||||
it("renders the invitation as HTML and plain text, keeping the URL", async () => {
|
||||
const { html, text } = await renderEmail(
|
||||
OrganizationInvitationEmail({
|
||||
appName: "Attendance",
|
||||
organizationName: "Acme Corp",
|
||||
inviterName: "Ada Lovelace",
|
||||
inviterEmail: "[email protected]",
|
||||
role: "member",
|
||||
invitationUrl: INVITATION_URL,
|
||||
expiresAt: EXPIRES,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain(INVITATION_URL);
|
||||
expect(html).toContain("Acme Corp");
|
||||
expect(html).toContain("Ada Lovelace");
|
||||
// Plain-text fallback must expose the clickable URL.
|
||||
expect(text).toContain(INVITATION_URL);
|
||||
expect(text).toContain("Acme Corp");
|
||||
});
|
||||
|
||||
it("renders the magic link email with the verify URL", async () => {
|
||||
const { html, text } = await renderEmail(
|
||||
MagicLinkEmail({
|
||||
appName: "Attendance",
|
||||
magicLinkUrl: "https://api.example.com/api/auth/magic-link/verify?token=t0k3n",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain("https://api.example.com/api/auth/magic-link/verify?token=t0k3n");
|
||||
expect(text).toContain("https://api.example.com/api/auth/magic-link/verify?token=t0k3n");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { render, toPlainText } from "@react-email/render";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
/**
|
||||
* Render a React Email component into an { html, text } pair.
|
||||
*
|
||||
* `@react-email/render` ships a `workerd` export condition, so the edge
|
||||
* build (async `render`) is used under `wrangler dev`/`wrangler deploy`.
|
||||
*/
|
||||
export async function renderEmail(element: ReactElement): Promise<{ html: string; text: string }> {
|
||||
const html = await render(element, { pretty: false });
|
||||
const text = toPlainText(html);
|
||||
return { html, text };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { sendViaResend } from "./transports/resend";
|
||||
import type { EmailEnvelope } from "./types";
|
||||
import { consoleTransport } from "./transports/console";
|
||||
|
||||
/** True when the auth server runs locally (Vite dev port varies). */
|
||||
function isLocalDev(): boolean {
|
||||
const baseURL = env.BETTER_AUTH_URL || "http://localhost:8787";
|
||||
return baseURL.startsWith("http://localhost") || baseURL.startsWith("http://127.0.0.1");
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a rendered envelope.
|
||||
*
|
||||
* Transport selection (all env reads are lazy so this module is safe to
|
||||
* import in the `auth generate` CLI context):
|
||||
* - `RESEND_API_KEY` set → Resend REST API.
|
||||
* - otherwise + local dev → console (never in production).
|
||||
* - otherwise → throw; a deployed worker without a
|
||||
* provider is a misconfiguration and must fail loudly.
|
||||
*/
|
||||
export async function sendEmail(envelope: EmailEnvelope): Promise<void> {
|
||||
if (env.RESEND_API_KEY) {
|
||||
await sendViaResend(envelope, {
|
||||
apiKey: env.RESEND_API_KEY,
|
||||
from: env.EMAIL_FROM,
|
||||
replyTo: env.EMAIL_REPLY_TO,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isLocalDev()) {
|
||||
await consoleTransport.send(envelope);
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
"Email delivery is not configured: set RESEND_API_KEY (production) or run locally to use the console transport.",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { EmailEnvelope, EmailTransport } from "../types";
|
||||
|
||||
/**
|
||||
* Development-only transport: prints the envelope to the worker console.
|
||||
*
|
||||
* This transport is only selected in local development (see `sendEmail`).
|
||||
* Never route production traffic here — auth URLs and invitation IDs must
|
||||
* not be logged outside local development.
|
||||
*/
|
||||
export const consoleTransport: EmailTransport = {
|
||||
async send(envelope: EmailEnvelope) {
|
||||
console.log(
|
||||
`\n[email:console] ───────────────────────────────\n` +
|
||||
`[email:console] to: ${envelope.to}\n` +
|
||||
`[email:console] subject: ${envelope.subject}\n` +
|
||||
`[email:console] ───────────────────────────────\n` +
|
||||
`${envelope.text}\n` +
|
||||
`[email:console] ───────────────────────────────\n`,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { EmailDeliveryError, type EmailEnvelope } from "../types";
|
||||
|
||||
const RESEND_ENDPOINT = "https://api.resend.com/emails";
|
||||
|
||||
export interface ResendConfig {
|
||||
/** Resend API key. */
|
||||
apiKey: string;
|
||||
/** Sender address, e.g. "Attendance <[email protected]>". */
|
||||
from: string;
|
||||
/** Optional reply-to address. */
|
||||
replyTo?: string;
|
||||
}
|
||||
|
||||
interface ResendErrorBody {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend delivery over plain `fetch` — no SDK dependency, fully
|
||||
* Cloudflare-Worker compatible.
|
||||
*
|
||||
* Errors are sanitized: the request body is never echoed back (it may
|
||||
* contain recipient data), and nothing is logged here. Callers decide
|
||||
* what (if anything) reaches the console.
|
||||
*/
|
||||
export async function sendViaResend(envelope: EmailEnvelope, config: ResendConfig): Promise<void> {
|
||||
const response = await fetch(RESEND_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
...(envelope.idempotencyKey ? { "Idempotency-Key": envelope.idempotencyKey } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: config.from,
|
||||
to: [envelope.to],
|
||||
...(config.replyTo ? { reply_to: config.replyTo } : {}),
|
||||
subject: envelope.subject,
|
||||
html: envelope.html,
|
||||
text: envelope.text,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `Resend request failed with status ${response.status}`;
|
||||
try {
|
||||
const body = (await response.json()) as ResendErrorBody;
|
||||
if (body.message) message = body.message;
|
||||
} catch {
|
||||
// Non-JSON error body — keep the status-only message.
|
||||
}
|
||||
throw new EmailDeliveryError(message, { cause: response.status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Minimal email envelope shared by every transport.
|
||||
*
|
||||
* The worker renders HTML + plain text itself (React Email) and hands a
|
||||
* fully-formed envelope to the transport, so the transport layer stays dumb
|
||||
* and provider-agnostic.
|
||||
*/
|
||||
export interface EmailEnvelope {
|
||||
/** Recipient email address. */
|
||||
to: string;
|
||||
/** Subject line. */
|
||||
subject: string;
|
||||
/** Rendered HTML body. */
|
||||
html: string;
|
||||
/** Plain-text fallback body. */
|
||||
text: string;
|
||||
/**
|
||||
* Optional idempotency key forwarded to providers that support it
|
||||
* (e.g. Resend), so retries cannot double-send.
|
||||
*/
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
/** A transport that delivers a rendered envelope. */
|
||||
export interface EmailTransport {
|
||||
send(envelope: EmailEnvelope): Promise<void>;
|
||||
} /** Raised when an email cannot be delivered (network, provider, config). */
|
||||
export class EmailDeliveryError extends Error {
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message, options);
|
||||
this.name = "EmailDeliveryError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { auth } from "../lib/auth/auth";
|
||||
|
||||
/**
|
||||
* Elysia plugin that:
|
||||
* 1. mounts the Better Auth handler (all /api/auth/* requests),
|
||||
* 2. adds an `auth: true` route flag that resolves the session and
|
||||
* injects typed `user` / `session` into handlers.
|
||||
*/
|
||||
export const betterAuthPlugin = new Elysia({ name: "better-auth" }).mount(auth.handler).macro({
|
||||
auth: {
|
||||
async resolve({ status, request }) {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
});
|
||||
if (!session) return status(401);
|
||||
|
||||
return {
|
||||
user: session.user,
|
||||
session: session.session,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig.json to read more about this file */
|
||||
|
||||
/* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
"target": "es2024",
|
||||
/* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
"lib": ["es2024"],
|
||||
/* Specify what JSX code is generated. */
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Specify what module code is generated. */
|
||||
"module": "esnext",
|
||||
/* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
"moduleResolution": "bundler",
|
||||
/* Enable importing .json files */
|
||||
"resolveJsonModule": true,
|
||||
|
||||
/* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
|
||||
"allowJs": true,
|
||||
/* Enable error reporting in type-checked JavaScript files. */
|
||||
"checkJs": false,
|
||||
|
||||
/* Disable emitting files from a compilation. */
|
||||
"noEmit": true,
|
||||
|
||||
/* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
"isolatedModules": true,
|
||||
/* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"allowSyntheticDefaultImports": true,
|
||||
/* Ensure that casing is correct in imports. */
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
/* Enable all strict type-checking options. */
|
||||
"strict": true,
|
||||
|
||||
/* Skip type checking all .d.ts files. */
|
||||
"skipLibCheck": true,
|
||||
"types": ["./worker-configuration.d.ts", "node"]
|
||||
}
|
||||
}
|
||||
+16064
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* For more details on how to configure Wrangler, refer to:
|
||||
* https://developers.cloudflare.com/workers/wrangler/configuration/
|
||||
*/
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "attendance-worker",
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2026-08-08",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"assets": {
|
||||
// The path to the directory containing the `index.html` file to be served at `/`
|
||||
"directory": "./public",
|
||||
},
|
||||
"vars": {
|
||||
// Public (non-secret) vars. Secrets go in `.dev.vars` locally and via
|
||||
// `wrangler secret put` in production.
|
||||
"BETTER_AUTH_URL": "http://localhost:8787",
|
||||
"TRUSTED_ORIGINS": "http://localhost:5173",
|
||||
"WEB_APP_URL": "http://localhost:5173",
|
||||
"EMAIL_FROM": "Attendance <[email protected]>",
|
||||
"EMAIL_REPLY_TO": "Attendance <[email protected]>",
|
||||
},
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "attendance-db",
|
||||
// Replace this placeholder after creating the remote database with:
|
||||
// wrangler d1 create attendance-db
|
||||
"database_id": "00000000-0000-0000-0000-000000000000",
|
||||
"migrations_dir": "drizzle",
|
||||
},
|
||||
],
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "MEMBER_GALLERY",
|
||||
"bucket_name": "attendance-member-gallery",
|
||||
},
|
||||
],
|
||||
"observability": {
|
||||
"enabled": true,
|
||||
},
|
||||
"upload_source_maps": true,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user