Files
hop.noname.company/apps/server/src/db/schema.ts

382 lines
12 KiB
TypeScript

import { sqliteTable, text, integer, index, uniqueIndex, check } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const users = sqliteTable('users', {
id: text().primaryKey(),
email: text().unique(),
displayName: text().notNull(),
passwordHash: text(),
role: text({ enum: ['user', 'admin'] })
.notNull()
.default('user'),
status: text({ enum: ['pending', 'active', 'suspended', 'deleted'] }).notNull(),
verifiedAt: integer(),
createdAt: integer().notNull(),
receivePolicy: text({ enum: ['requests', 'allowed', 'none'] })
.notNull()
.default('requests'),
});
export const accountSessions = sqliteTable(
'account_sessions',
{
id: text().primaryKey(),
userId: text()
.notNull()
.references(() => users.id),
tokenHash: text().notNull().unique(),
createdAt: integer().notNull(),
lastSeen: integer().notNull(),
expiresAt: integer().notNull(),
revokedAt: integer(),
},
(t) => [index('account_session_user').on(t.userId, t.expiresAt)],
);
export const accountTokens = sqliteTable(
'account_tokens',
{
id: text().primaryKey(),
userId: text()
.notNull()
.references(() => users.id),
purpose: text({ enum: ['verify', 'reset', 'email-change'] }).notNull(),
tokenHash: text().notNull().unique(),
value: text(),
createdAt: integer().notNull(),
expiresAt: integer().notNull(),
usedAt: integer(),
},
(t) => [index('account_token_expiry').on(t.expiresAt)],
);
export const rules = sqliteTable(
'user_rules',
{
id: text().primaryKey(),
userId: text()
.notNull()
.references(() => users.id),
targetUserId: text()
.notNull()
.references(() => users.id),
action: text({ enum: ['allow', 'block'] }).notNull(),
},
(t) => [
uniqueIndex('user_rule_pair').on(t.userId, t.targetUserId),
check('rule_not_self', sql`${t.userId} <> ${t.targetUserId}`),
],
);
export const guests = sqliteTable(
'guest_sessions',
{
id: text().primaryKey(),
tokenHash: text().notNull().unique(),
expiresAt: integer().notNull(),
revokedAt: integer(),
createdAt: integer().notNull(),
},
(t) => [index('guest_expiry').on(t.expiresAt)],
);
export const devices = sqliteTable(
'browser_devices',
{
id: text().primaryKey(),
guestId: text()
.notNull()
.unique()
.references(() => guests.id),
name: text().notNull(),
code: text().notNull().unique(),
discoverable: integer({ mode: 'boolean' }).notNull().default(true),
lastSeen: integer().notNull(),
},
(t) => [check('device_name_length', sql`length(${t.name}) between 1 and 40`)],
);
export const spaces = sqliteTable(
'connection_spaces',
{
id: text().primaryKey(),
creatorGuestId: text()
.notNull()
.references(() => guests.id),
tokenHash: text().notNull().unique(),
codeHash: text().notNull().unique(),
approvalRequired: integer({ mode: 'boolean' }).notNull(),
expiresAt: integer().notNull(),
closedAt: integer(),
createdAt: integer().notNull(),
},
(t) => [index('space_expiry').on(t.expiresAt)],
);
export const members = sqliteTable(
'space_members',
{
id: text().primaryKey(),
spaceId: text()
.notNull()
.references(() => spaces.id),
deviceId: text()
.notNull()
.references(() => devices.id),
status: text({ enum: ['pending', 'approved', 'removed'] }).notNull(),
joinedAt: integer().notNull(),
},
(t) => [
uniqueIndex('space_device').on(t.spaceId, t.deviceId),
uniqueIndex('member_single_active_device')
.on(t.deviceId)
.where(sql`${t.status} IN ('pending', 'approved')`),
],
);
export const blocks = sqliteTable(
'guest_blocks',
{
id: text().primaryKey(),
guestId: text()
.notNull()
.references(() => guests.id),
targetGuestId: text()
.notNull()
.references(() => guests.id),
},
(t) => [uniqueIndex('guest_block_pair').on(t.guestId, t.targetGuestId)],
);
export const requests = sqliteTable(
'transfer_requests',
{
id: text().primaryKey(),
mode: text({ enum: ['local', 'email'] })
.notNull()
.default('local'),
senderGuestId: text().references(() => guests.id),
recipientGuestId: text().references(() => guests.id),
senderUserId: text().references(() => users.id),
recipientUserId: text().references(() => users.id),
kind: text({ enum: ['text', 'url', 'file'] }).notNull(),
byteSize: integer().notNull(),
manifest: text(),
note: text(),
count: integer().notNull(),
status: text({ enum: ['pending', 'accepted', 'rejected', 'expired', 'cancelled'] }).notNull(),
createdAt: integer().notNull(),
expiresAt: integer().notNull(),
acceptedAt: integer(),
rejectedAt: integer(),
startBy: integer(),
},
(t) => [
index('requests_sender_time').on(t.senderGuestId, t.createdAt),
index('requests_recipient_time').on(t.recipientGuestId, t.createdAt),
index('requests_expiry').on(t.status, t.expiresAt),
check(
'request_different_guests',
sql`${t.mode} <> 'local' OR ${t.senderGuestId} <> ${t.recipientGuestId}`,
),
check(
'request_sender_subject',
sql`(${t.senderGuestId} IS NOT NULL) + (${t.senderUserId} IS NOT NULL) = 1`,
),
check(
'request_recipient_subject',
sql`(${t.recipientGuestId} IS NOT NULL) + (${t.recipientUserId} IS NOT NULL) = 1`,
),
check(
'request_mode_subjects',
sql`(${t.mode} = 'local' AND ${t.senderGuestId} IS NOT NULL AND ${t.recipientGuestId} IS NOT NULL) OR (${t.mode} = 'email' AND ${t.senderUserId} IS NOT NULL AND ${t.recipientUserId} IS NOT NULL)`,
),
index('requests_sender_user').on(t.senderUserId, t.createdAt),
index('requests_recipient_user').on(t.recipientUserId, t.createdAt),
],
);
export const batches = sqliteTable('transfer_batches', {
id: text().primaryKey(),
requestId: text()
.notNull()
.unique()
.references(() => requests.id),
status: text({ enum: ['waiting', 'uploading', 'stored', 'failed'] }).notNull(),
storedAt: integer(),
});
export const files = sqliteTable(
'files',
{
id: text().primaryKey(),
requestId: text()
.notNull()
.references(() => requests.id),
ownerGuestId: text().references(() => guests.id),
ownerUserId: text().references(() => users.id),
position: integer().notNull(),
storageKey: text().notNull().unique(),
name: text().notNull(),
type: text().notNull(),
size: integer().notNull(),
checksum: text(),
status: text({ enum: ['pending', 'uploading', 'stored', 'failed', 'cleaning'] }).notNull(),
uploadId: text(),
uploadKey: text(),
updatedAt: integer().notNull(),
},
(t) => [
uniqueIndex('request_file_position').on(t.requestId, t.position),
index('files_owner_state').on(t.ownerGuestId, t.status),
check('file_nonnegative_size', sql`${t.size} >= 0`),
check(
'file_owner_subject',
sql`(${t.ownerGuestId} IS NOT NULL) + (${t.ownerUserId} IS NOT NULL) = 1`,
),
],
);
export const fileUploadContexts = sqliteTable(
'file_upload_contexts',
{
fileId: text()
.primaryKey()
.references(() => files.id, { onDelete: 'cascade' }),
context: text().notNull(),
expiresAt: integer().notNull(),
},
(t) => [index('file_upload_context_expiry').on(t.expiresAt)],
);
export const reservations = sqliteTable(
'quota_reservations',
{
id: text().primaryKey(),
fileId: text()
.notNull()
.unique()
.references(() => files.id),
userId: text().references(() => users.id),
guestId: text().references(() => guests.id),
bytes: integer().notNull(),
expiresAt: integer().notNull(),
},
(t) => [
check('reservations_subject', sql`(${t.guestId} IS NOT NULL) + (${t.userId} IS NOT NULL) = 1`),
index('quota_guest_expiry').on(t.guestId, t.expiresAt),
check('reservation_nonnegative', sql`${t.bytes} >= 0`),
],
);
export const deletions = sqliteTable(
'deletion_operations',
{
id: text().primaryKey(),
userId: text().references(() => users.id),
guestId: text().references(() => guests.id),
undoUntil: integer().notNull(),
createdAt: integer().notNull(),
restoredAt: integer(),
},
(t) => [
check('deletion_subject', sql`(${t.guestId} IS NOT NULL) + (${t.userId} IS NOT NULL) = 1`),
],
);
export const deletionTargets = sqliteTable('deletion_targets', {
id: text().primaryKey(),
operationId: text()
.notNull()
.references(() => deletions.id),
accessId: text().notNull(),
});
export const items = sqliteTable(
'items',
{
id: text().primaryKey(),
batchId: text()
.notNull()
.references(() => batches.id),
kind: text({ enum: ['text', 'url', 'file'] }).notNull(),
text: text(),
url: text(),
fileId: text().references(() => files.id),
createdAt: integer().notNull(),
expiresAt: integer().notNull(),
},
(t) => [
index('items_expiry').on(t.expiresAt),
check(
'item_payload',
sql`(${t.kind} = 'text' AND ${t.text} IS NOT NULL AND ${t.url} IS NULL AND ${t.fileId} IS NULL) OR (${t.kind} = 'url' AND ${t.url} IS NOT NULL AND ${t.text} IS NULL AND ${t.fileId} IS NULL) OR (${t.kind} = 'file' AND ${t.fileId} IS NOT NULL AND ${t.text} IS NULL AND ${t.url} IS NULL)`,
),
],
);
export const access = sqliteTable(
'item_access',
{
id: text().primaryKey(),
itemId: text()
.notNull()
.references(() => items.id),
userId: text().references(() => users.id),
guestId: text().references(() => guests.id),
role: text({ enum: ['sender', 'recipient', 'self'] }).notNull(),
read: integer({ mode: 'boolean' }).notNull().default(false),
deletedAt: integer(),
favorite: integer({ mode: 'boolean' }).notNull().default(false),
note: text(),
},
(t) => [
check('access_subject', sql`(${t.guestId} IS NOT NULL) + (${t.userId} IS NOT NULL) = 1`),
uniqueIndex('access_subject_role').on(t.itemId, t.guestId, t.role),
uniqueIndex('access_account_role').on(t.itemId, t.userId, t.role),
index('access_account').on(t.userId, t.deletedAt),
index('access_guest').on(t.guestId, t.deletedAt),
],
);
export const receiptAccounts = sqliteTable('request_receipt_accounts', {
requestId: text()
.primaryKey()
.references(() => requests.id),
userId: text()
.notNull()
.references(() => users.id),
sessionId: text()
.notNull()
.references(() => accountSessions.id),
});
export const vaultTransfers = sqliteTable('vault_transfers', {
requestId: text()
.primaryKey()
.references(() => requests.id),
accessId: text()
.notNull()
.references(() => access.id),
userId: text()
.notNull()
.references(() => users.id),
sessionId: text()
.notNull()
.references(() => accountSessions.id),
});
export const idempotency = sqliteTable(
'idempotency_records',
{
id: text().primaryKey(),
userId: text().references(() => users.id),
guestId: text().references(() => guests.id),
operation: text().notNull(),
key: text().notNull(),
payloadHash: text().notNull(),
response: text().notNull(),
createdAt: integer().notNull(),
},
(t) => [
uniqueIndex('idempotency_scope').on(t.guestId, t.operation, t.key),
uniqueIndex('account_idempotency_scope').on(t.userId, t.operation, t.key),
check('idempotency_subject', sql`(${t.guestId} IS NOT NULL) + (${t.userId} IS NOT NULL) = 1`),
],
);
export const adminSettings = sqliteTable('admin_settings', {
name: text().primaryKey(),
value: text().notNull(),
updatedAt: integer().notNull(),
});
export const adminAudit = sqliteTable(
'admin_audit',
{
id: text().primaryKey(),
actorUserId: text().references(() => users.id, { onDelete: 'set null' }),
action: text().notNull(),
details: text().notNull(),
createdAt: integer().notNull(),
},
(t) => [index('admin_audit_created').on(t.createdAt)],
);