Add database schema and Kysely packages

- Create initial database migration with full auth schema:
  - users, sessions, passkeys, devices tables
  - orgs, org_members, org_sites, org_invites tables
  - email_verifications, password_resets, login_requests tables
  - Indexes for common lookups and cleanup jobs
- Add @reviq/db-schema package with kysely-codegen
- Add @reviq/db package with Kysely client

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
RevIQ
2026-01-09 11:44:36 +08:00
parent 322155b4a1
commit 392d976812
12 changed files with 1676 additions and 0 deletions

View File

@@ -0,0 +1,220 @@
-- migrate:up
-- Enums
CREATE TYPE org_role AS ENUM ('owner', 'admin', 'member');
CREATE TYPE passkey_device_type AS ENUM ('singleDevice', 'multiDevice');
-- Users (authentication)
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
email_verified_at TIMESTAMPTZ, -- NULL until verified
full_name TEXT, -- Legal/full name (set in profile setup)
display_name TEXT, -- Preferred display name (set in profile setup)
phone_number TEXT, -- E.164 format, validated with libphonenumber-js
avatar_url TEXT,
password_hash TEXT, -- NULL if using passkey only
require_passkey BOOLEAN NOT NULL DEFAULT false, -- If true, must use passkey to login
is_superuser BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Email verification tokens
CREATE TABLE email_verifications (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT UNIQUE NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Password reset tokens
CREATE TABLE password_resets (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT UNIQUE NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ, -- Set when token is used
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- WebAuthn challenges (short-lived, for passkey registration/authentication)
CREATE TABLE webauthn_challenges (
id BIGSERIAL PRIMARY KEY,
options JSONB NOT NULL, -- Full options object from simplewebauthn
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Passkeys (WebAuthn credentials)
CREATE TABLE passkeys (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id BYTEA UNIQUE NOT NULL,
public_key BYTEA NOT NULL,
webauthn_user_id TEXT UNIQUE NOT NULL,
counter BIGINT NOT NULL DEFAULT 0,
device_type passkey_device_type NOT NULL,
backup_eligible BOOLEAN NOT NULL,
backup_status BOOLEAN NOT NULL,
transports JSONB, -- ['usb', 'ble', 'nfc', 'internal']
rpid TEXT NOT NULL,
name TEXT NOT NULL, -- e.g. "MacBook Touch ID"
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- User devices (tracks all devices a user has logged in from)
-- Note: device_fingerprint is NOT unique - multiple users can share the same
-- browser/device (e.g., shared family computer, work terminal)
CREATE TABLE user_devices (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device_fingerprint TEXT NOT NULL, -- Random ID stored in cookie
name TEXT, -- e.g. "Chrome on MacOS"
is_trusted BOOLEAN NOT NULL DEFAULT false,
user_agent TEXT NOT NULL,
ip_address TEXT,
city TEXT,
region TEXT,
country TEXT,
last_used_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(user_id, device_fingerprint) -- One device record per user+fingerprint
);
-- Sessions (token_hash stores SHA-256 hash of token, not raw token)
-- Sessions are kept for audit history, not deleted by cron
CREATE TABLE sessions (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device_id BIGINT REFERENCES user_devices(id) ON DELETE SET NULL,
token_hash TEXT UNIQUE NOT NULL, -- SHA-256 hash of session token
trusted_mode BOOLEAN NOT NULL, -- True if passkey or email-confirmed login
ip_address TEXT,
city TEXT,
region TEXT,
country TEXT,
user_agent TEXT,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ, -- NULL if active, set when logged out
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- API tokens (for CLI and programmatic access)
CREATE TABLE api_tokens (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL, -- e.g. "CLI token", "CI/CD"
token_hash TEXT UNIQUE NOT NULL, -- SHA-256 hash of token
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Login requests (tracks login attempts for polling and email confirmation)
-- Deleted after successful login via loginIfRequestIsCompleted()
-- Note: Only created for existing users. Non-existent users get a fake token (anti-enumeration).
CREATE TABLE login_requests (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
email TEXT NOT NULL, -- Email entered by user (normalized lowercase)
token TEXT UNIQUE, -- Token sent in email link (NULL until email sent)
device_fingerprint TEXT, -- From cookie at login request time
ip_address TEXT,
city TEXT,
region TEXT,
country TEXT,
user_agent TEXT,
completed_at TIMESTAMPTZ, -- NULL until login completed
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Orgs (companies/accounts)
CREATE TABLE orgs (
id SERIAL PRIMARY KEY,
slug TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
logo_url TEXT, -- Cloudflare R2 URL
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Org members (users belonging to orgs)
CREATE TABLE org_members (
id SERIAL PRIMARY KEY,
org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role org_role NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(org_id, user_id)
);
-- Org sites (maps orgs to domains for report access)
CREATE TABLE org_sites (
id SERIAL PRIMARY KEY,
org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
domain TEXT NOT NULL, -- key used to filter reports
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(domain)
);
-- Org invites (pending invitations)
CREATE TABLE org_invites (
id SERIAL PRIMARY KEY,
org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
email TEXT NOT NULL,
role org_role NOT NULL DEFAULT 'member',
invited_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT UNIQUE NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(org_id, email)
);
-- Indexes (UNIQUE constraints already create indexes, so we skip those)
-- Note: orgs.slug has an index via UNIQUE constraint (used for all org API lookups)
-- Lookup indexes
CREATE INDEX idx_passkeys_user ON passkeys(user_id);
CREATE INDEX idx_user_devices_user ON user_devices(user_id);
CREATE INDEX idx_user_devices_fingerprint ON user_devices(device_fingerprint);
CREATE INDEX idx_sessions_user ON sessions(user_id);
CREATE INDEX idx_sessions_device ON sessions(device_id);
CREATE INDEX idx_api_tokens_user ON api_tokens(user_id);
CREATE INDEX idx_login_requests_user ON login_requests(user_id);
CREATE INDEX idx_password_resets_user ON password_resets(user_id);
CREATE INDEX idx_org_members_user ON org_members(user_id);
CREATE INDEX idx_org_members_org ON org_members(org_id);
CREATE INDEX idx_org_sites_org ON org_sites(org_id);
CREATE INDEX idx_org_invites_email ON org_invites(email);
-- Active session lookup (excludes revoked sessions)
CREATE INDEX idx_sessions_active ON sessions(token_hash) WHERE revoked_at IS NULL;
-- Cleanup job indexes (for deleting expired rows)
-- Note: sessions are NOT deleted, kept for audit history (use revoked_at instead)
CREATE INDEX idx_api_tokens_expires ON api_tokens(expires_at);
CREATE INDEX idx_email_verifications_expires ON email_verifications(expires_at);
CREATE INDEX idx_password_resets_expires ON password_resets(expires_at);
CREATE INDEX idx_webauthn_challenges_created ON webauthn_challenges(created_at);
CREATE INDEX idx_org_invites_expires ON org_invites(expires_at);
CREATE INDEX idx_login_requests_expires ON login_requests(expires_at);
-- migrate:down
DROP TABLE IF EXISTS org_invites;
DROP TABLE IF EXISTS org_sites;
DROP TABLE IF EXISTS org_members;
DROP TABLE IF EXISTS orgs;
DROP TABLE IF EXISTS login_requests;
DROP TABLE IF EXISTS api_tokens;
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS user_devices;
DROP TABLE IF EXISTS passkeys;
DROP TABLE IF EXISTS webauthn_challenges;
DROP TABLE IF EXISTS password_resets;
DROP TABLE IF EXISTS email_verifications;
DROP TABLE IF EXISTS users;
DROP TYPE IF EXISTS passkey_device_type;
DROP TYPE IF EXISTS org_role;

1080
db/schema.sql Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
import { configs } from "@macalinao/eslint-config";
export default [
...configs.fast,
{
languageOptions: {
parserOptions: {
tsconfigRootDir: import.meta.dirname,
},
},
},
];

View File

@@ -0,0 +1,26 @@
{
"name": "@reviq/db-schema",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc",
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
"lint": "eslint . --cache",
"generate": "kysely-codegen --dialect postgres --url $DATABASE_URL --out-file src/types.ts"
},
"dependencies": {
"kysely": "^0.28.9",
"pg": "^8.13.1"
},
"devDependencies": {
"kysely-codegen": "^0.19.0",
"@types/node": "^25.0.3",
"@types/pg": "^8.11.10",
"@macalinao/tsconfig": "catalog:",
"typescript": "catalog:"
}
}

View File

@@ -0,0 +1,10 @@
/**
* Database schema types generated from PostgreSQL schema
*
* @module @reviq/db-schema
*/
export * from "./types.js";
// Re-export DB as Database for convenience
export type { DB as Database } from "./types.js";

View File

@@ -0,0 +1,199 @@
/**
* This file was generated by kysely-codegen.
* Please do not edit it manually.
*/
import type { ColumnType } from "kysely";
export type Generated<T> =
T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>;
export type Int8 = ColumnType<string, bigint | number | string>;
export type Json = JsonValue;
export type JsonArray = JsonValue[];
export type JsonObject = {
[x: string]: JsonValue | undefined;
};
export type JsonPrimitive = boolean | number | string | null;
export type JsonValue = JsonArray | JsonObject | JsonPrimitive;
export type OrgRole = "admin" | "member" | "owner";
export type PasskeyDeviceType = "multiDevice" | "singleDevice";
export type Timestamp = ColumnType<Date, Date | string>;
export interface ApiTokens {
created_at: Generated<Timestamp>;
expires_at: Timestamp;
id: Generated<Int8>;
last_used_at: Timestamp | null;
name: string;
token_hash: string;
user_id: number;
}
export interface EmailVerifications {
created_at: Generated<Timestamp>;
expires_at: Timestamp;
id: Generated<Int8>;
token: string;
user_id: number;
}
export interface LoginRequests {
city: string | null;
completed_at: Timestamp | null;
country: string | null;
created_at: Generated<Timestamp>;
device_fingerprint: string | null;
email: string;
expires_at: Timestamp;
id: Generated<Int8>;
ip_address: string | null;
region: string | null;
token: string | null;
user_agent: string | null;
user_id: number;
}
export interface OrgInvites {
created_at: Generated<Timestamp>;
email: string;
expires_at: Timestamp;
id: Generated<number>;
invited_by: number;
org_id: number;
role: Generated<OrgRole>;
token: string;
}
export interface OrgMembers {
created_at: Generated<Timestamp>;
id: Generated<number>;
org_id: number;
role: Generated<OrgRole>;
user_id: number;
}
export interface Orgs {
created_at: Generated<Timestamp>;
display_name: string;
id: Generated<number>;
logo_url: string | null;
slug: string;
updated_at: Generated<Timestamp>;
}
export interface OrgSites {
created_at: Generated<Timestamp>;
domain: string;
id: Generated<number>;
org_id: number;
}
export interface Passkeys {
backup_eligible: boolean;
backup_status: boolean;
counter: Generated<Int8>;
created_at: Generated<Timestamp>;
credential_id: Buffer;
device_type: PasskeyDeviceType;
id: Generated<Int8>;
last_used_at: Timestamp | null;
name: string;
public_key: Buffer;
rpid: string;
transports: Json | null;
user_id: number;
webauthn_user_id: string;
}
export interface PasswordResets {
created_at: Generated<Timestamp>;
expires_at: Timestamp;
id: Generated<Int8>;
token: string;
used_at: Timestamp | null;
user_id: number;
}
export interface SchemaMigrations {
version: string;
}
export interface Sessions {
city: string | null;
country: string | null;
created_at: Generated<Timestamp>;
device_id: Int8 | null;
expires_at: Timestamp;
id: Generated<Int8>;
ip_address: string | null;
region: string | null;
revoked_at: Timestamp | null;
token_hash: string;
trusted_mode: boolean;
user_agent: string | null;
user_id: number;
}
export interface UserDevices {
city: string | null;
country: string | null;
created_at: Generated<Timestamp>;
device_fingerprint: string;
id: Generated<Int8>;
ip_address: string | null;
is_trusted: Generated<boolean>;
last_used_at: Generated<Timestamp>;
name: string | null;
region: string | null;
user_agent: string;
user_id: number;
}
export interface Users {
avatar_url: string | null;
created_at: Generated<Timestamp>;
display_name: string | null;
email: string;
email_verified_at: Timestamp | null;
full_name: string | null;
id: Generated<number>;
is_superuser: Generated<boolean>;
password_hash: string | null;
phone_number: string | null;
require_passkey: Generated<boolean>;
updated_at: Generated<Timestamp>;
}
export interface WebauthnChallenges {
created_at: Generated<Timestamp>;
id: Generated<Int8>;
options: Json;
}
export interface DB {
api_tokens: ApiTokens;
email_verifications: EmailVerifications;
login_requests: LoginRequests;
org_invites: OrgInvites;
org_members: OrgMembers;
org_sites: OrgSites;
orgs: Orgs;
passkeys: Passkeys;
password_resets: PasswordResets;
schema_migrations: SchemaMigrations;
sessions: Sessions;
user_devices: UserDevices;
users: Users;
webauthn_challenges: WebauthnChallenges;
}

View File

@@ -0,0 +1,15 @@
{
"extends": "@macalinao/tsconfig/tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"composite": true,
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,12 @@
import { configs } from "@macalinao/eslint-config";
export default [
...configs.fast,
{
languageOptions: {
parserOptions: {
tsconfigRootDir: import.meta.dirname,
},
},
},
];

25
packages/db/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "@reviq/db",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc",
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
"lint": "eslint . --cache"
},
"dependencies": {
"@reviq/db-schema": "workspace:*",
"kysely": "^0.28.9",
"pg": "^8.13.1"
},
"devDependencies": {
"@types/node": "^25.0.3",
"@types/pg": "^8.11.10",
"@macalinao/tsconfig": "catalog:",
"typescript": "catalog:"
}
}

37
packages/db/src/client.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* Kysely database client
*
* @module @reviq/db
*/
import { Kysely, PostgresDialect } from "kysely";
import type { Database } from "@reviq/db-schema";
import pg from "pg";
const { Pool } = pg;
/**
* Creates a new Kysely database client
*
* @param connectionString - PostgreSQL connection string (defaults to DATABASE_URL env var)
* @returns Kysely database instance
*/
export const createDb = (
connectionString: string = process.env.DATABASE_URL || ""
): Kysely<Database> => {
if (!connectionString) {
throw new Error(
"Database connection string is required. Set DATABASE_URL environment variable."
);
}
const dialect = new PostgresDialect({
pool: new Pool({
connectionString,
}),
});
return new Kysely<Database>({
dialect,
});
};

26
packages/db/src/index.ts Normal file
View File

@@ -0,0 +1,26 @@
/**
* Database client for RevIQ Publisher Dashboard
*
* @module @reviq/db
*/
import type { Kysely } from "kysely";
import type { Database } from "@reviq/db-schema";
import { createDb } from "./client.js";
/**
* Default database instance
*
* Uses DATABASE_URL environment variable for connection
*/
export const db: Kysely<Database> = createDb();
/**
* Export createDb for creating custom database instances
*/
export { createDb } from "./client.js";
/**
* Re-export database types from db-schema
*/
export type { Database } from "@reviq/db-schema";

14
packages/db/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"extends": "@macalinao/tsconfig/tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}