chore: add pnpm workspace configuration with package definitions and overrides

This commit is contained in:
snoozescript
2026-08-14 23:35:45 +05:30
parent ae2cb896a6
commit bcb302ac7d
389 changed files with 65206 additions and 0 deletions
+10
View File
@@ -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=
+12
View File
@@ -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
+171
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
{
"printWidth": 140,
"singleQuote": true,
"semi": true,
"useTabs": true
}
+5
View File
@@ -0,0 +1,5 @@
{
"files.associations": {
"wrangler.json": "jsonc"
}
}
+41
View File
@@ -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/
+133
View File
@@ -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
```
+9
View File
@@ -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`);
+73
View File
@@ -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`);
+26
View File
@@ -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;
+17
View File
@@ -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`);
+714
View File
@@ -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
+34
View File
@@ -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
}
]
}
+39
View File
@@ -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"
}
}
+23
View File
@@ -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>
+110
View File
@@ -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`;
+155
View File
@@ -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],
}),
}));
+279
View File
@@ -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],
}),
}));
+9
View File
@@ -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),
],
);
+3
View File
@@ -0,0 +1,3 @@
export * from "./auth-schema";
export * from "./attendance-schema";
export * from "./member-gallery-schema";
+44
View File
@@ -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&apos;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&apos;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&apos;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&apos;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&apos;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;
+30
View File
@@ -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;
}
}
+436
View File
@@ -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;
+139
View File
@@ -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,
}),
],
};
}
+30
View File
@@ -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,
}),
),
);
+40
View File
@@ -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");
});
});
+14
View File
@@ -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 };
}
+38
View File
@@ -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 });
}
}
+33
View File
@@ -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,
};
},
},
});
+41
View File
@@ -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"]
}
}
File diff suppressed because it is too large Load Diff
+44
View File
@@ -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