202 lines
7.2 KiB
TypeScript
202 lines
7.2 KiB
TypeScript
import pg from "pg";
|
|
import { createId } from "./security.js";
|
|
|
|
const { Pool } = pg;
|
|
|
|
export const pool = new Pool({
|
|
connectionString: process.env.DATABASE_URL ?? "postgres://cents:cents-dev-only@127.0.0.1:55432/cents",
|
|
max: 10,
|
|
});
|
|
|
|
const schema = `
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id text PRIMARY KEY,
|
|
name varchar(40) NOT NULL CHECK (length(trim(name)) BETWEEN 1 AND 40),
|
|
name_key varchar(40),
|
|
password_hash text,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS name_key varchar(40);
|
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash text;
|
|
UPDATE users SET name_key = lower(trim(name)) WHERE name_key IS NULL;
|
|
CREATE UNIQUE INDEX IF NOT EXISTS users_name_key_unique ON users (name_key);
|
|
|
|
CREATE TABLE IF NOT EXISTS ledgers (
|
|
id text PRIMARY KEY,
|
|
name varchar(40) NOT NULL,
|
|
color varchar(16) NOT NULL DEFAULT '#087f72',
|
|
theme varchar(24) NOT NULL DEFAULT 'jade',
|
|
default_currency varchar(3) NOT NULL DEFAULT 'CNY',
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
archived_at timestamptz
|
|
);
|
|
|
|
ALTER TABLE ledgers ADD COLUMN IF NOT EXISTS theme varchar(24) NOT NULL DEFAULT 'jade';
|
|
UPDATE ledgers SET theme = CASE lower(color)
|
|
WHEN '#3478e5' THEN 'ocean'
|
|
WHEN '#7559d9' THEN 'berry'
|
|
WHEN '#f05d3f' THEN 'coral'
|
|
WHEN '#d84486' THEN 'berry'
|
|
WHEN '#15956d' THEN 'meadow'
|
|
ELSE 'jade'
|
|
END WHERE theme = 'jade' AND lower(color) <> '#087f72';
|
|
ALTER TABLE ledgers ADD COLUMN IF NOT EXISTS personal_owner_id text REFERENCES users(id) ON DELETE CASCADE;
|
|
CREATE UNIQUE INDEX IF NOT EXISTS ledgers_personal_owner_unique
|
|
ON ledgers (personal_owner_id) WHERE personal_owner_id IS NOT NULL;
|
|
|
|
CREATE TABLE IF NOT EXISTS ledger_members (
|
|
ledger_id text NOT NULL REFERENCES ledgers(id) ON DELETE CASCADE,
|
|
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
role varchar(8) NOT NULL CHECK (role IN ('owner', 'member')),
|
|
joined_at timestamptz NOT NULL DEFAULT now(),
|
|
removed_at timestamptz,
|
|
PRIMARY KEY (ledger_id, user_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS ledger_invitations (
|
|
id text PRIMARY KEY,
|
|
ledger_id text NOT NULL REFERENCES ledgers(id) ON DELETE CASCADE,
|
|
key_hash char(64) NOT NULL UNIQUE,
|
|
role varchar(8) NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'member')),
|
|
created_by text REFERENCES users(id) ON DELETE SET NULL,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
expires_at timestamptz NOT NULL,
|
|
accepted_by text REFERENCES users(id) ON DELETE SET NULL,
|
|
accepted_at timestamptz,
|
|
revoked_at timestamptz
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS app_invitations (
|
|
id text PRIMARY KEY,
|
|
key_hash char(64) NOT NULL UNIQUE,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
expires_at timestamptz NOT NULL,
|
|
accepted_by text REFERENCES users(id) ON DELETE SET NULL,
|
|
accepted_at timestamptz,
|
|
revoked_at timestamptz
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS app_invitations_lookup
|
|
ON app_invitations (key_hash, expires_at);
|
|
|
|
CREATE INDEX IF NOT EXISTS ledger_invitations_lookup
|
|
ON ledger_invitations (key_hash, expires_at);
|
|
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id text PRIMARY KEY,
|
|
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
token_hash char(64) NOT NULL UNIQUE,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
expires_at timestamptz NOT NULL,
|
|
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
|
revoked_at timestamptz
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS sessions_lookup ON sessions (token_hash, expires_at);
|
|
|
|
CREATE TABLE IF NOT EXISTS entries (
|
|
id text PRIMARY KEY,
|
|
owner_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
type varchar(8) NOT NULL CHECK (type IN ('expense', 'income')),
|
|
amount integer NOT NULL CHECK (amount > 0),
|
|
currency varchar(3) NOT NULL,
|
|
base_currency varchar(3) NOT NULL,
|
|
base_amount integer NOT NULL CHECK (base_amount > 0),
|
|
exchange_rate varchar(32) NOT NULL,
|
|
exchange_rate_source varchar(8) NOT NULL CHECK (exchange_rate_source IN ('manual', 'system')),
|
|
category_id varchar(100) NOT NULL,
|
|
note varchar(500) NOT NULL DEFAULT '',
|
|
occurred_at timestamptz NOT NULL,
|
|
created_by text REFERENCES users(id) ON DELETE SET NULL,
|
|
updated_by text REFERENCES users(id) ON DELETE SET NULL,
|
|
created_at timestamptz NOT NULL,
|
|
updated_at timestamptz NOT NULL,
|
|
deleted_at timestamptz,
|
|
version integer NOT NULL CHECK (version > 0),
|
|
server_updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS owner_id text REFERENCES users(id) ON DELETE CASCADE;
|
|
UPDATE entries SET owner_id = COALESCE(created_by, updated_by) WHERE owner_id IS NULL;
|
|
ALTER TABLE entries ALTER COLUMN owner_id SET NOT NULL;
|
|
|
|
CREATE TABLE IF NOT EXISTS entry_ledgers (
|
|
entry_id text NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
|
ledger_id text NOT NULL REFERENCES ledgers(id) ON DELETE CASCADE,
|
|
linked_at timestamptz NOT NULL DEFAULT now(),
|
|
unlinked_at timestamptz,
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (entry_id, ledger_id)
|
|
);
|
|
|
|
ALTER TABLE entry_ledgers ADD COLUMN IF NOT EXISTS unlinked_at timestamptz;
|
|
ALTER TABLE entry_ledgers ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
|
|
|
DO $$
|
|
BEGIN
|
|
IF EXISTS (
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_schema = 'public' AND table_name = 'entries' AND column_name = 'ledger_id'
|
|
) THEN
|
|
EXECUTE 'INSERT INTO entry_ledgers (entry_id, ledger_id)
|
|
SELECT id, ledger_id FROM entries
|
|
ON CONFLICT (entry_id, ledger_id) DO NOTHING';
|
|
END IF;
|
|
END $$;
|
|
|
|
DROP INDEX IF EXISTS entries_ledger_occurred;
|
|
ALTER TABLE entries DROP COLUMN IF EXISTS ledger_id;
|
|
|
|
CREATE INDEX IF NOT EXISTS entries_owner_occurred
|
|
ON entries (owner_id, occurred_at DESC);
|
|
CREATE INDEX IF NOT EXISTS entry_ledgers_ledger
|
|
ON entry_ledgers (ledger_id, entry_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS entry_sync_operations (
|
|
id text PRIMARY KEY,
|
|
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
processed_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
`;
|
|
|
|
export async function initializeDatabase() {
|
|
await pool.query(schema);
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query("BEGIN");
|
|
const users = await client.query<{ id: string }>(
|
|
`SELECT u.id FROM users u
|
|
WHERE NOT EXISTS (SELECT 1 FROM ledgers l WHERE l.personal_owner_id = u.id)
|
|
FOR UPDATE`,
|
|
);
|
|
for (const user of users.rows) {
|
|
const ledgerId = createId();
|
|
await client.query(
|
|
`INSERT INTO ledgers (id, name, color, default_currency, personal_owner_id)
|
|
VALUES ($1, '个人账本', '#087f72', 'CNY', $2)`,
|
|
[ledgerId, user.id],
|
|
);
|
|
await client.query(
|
|
"INSERT INTO ledger_members (ledger_id, user_id, role) VALUES ($1, $2, 'owner')",
|
|
[ledgerId, user.id],
|
|
);
|
|
}
|
|
await client.query(
|
|
`INSERT INTO entry_ledgers (entry_id, ledger_id)
|
|
SELECT e.id, l.id FROM entries e JOIN ledgers l ON l.personal_owner_id = e.owner_id
|
|
ON CONFLICT (entry_id, ledger_id) DO UPDATE
|
|
SET unlinked_at = NULL, updated_at = now()
|
|
WHERE entry_ledgers.unlinked_at IS NOT NULL`,
|
|
);
|
|
await client.query("COMMIT");
|
|
} catch (error) {
|
|
await client.query("ROLLBACK");
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|