-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathschema.ts
More file actions
339 lines (326 loc) · 15 KB
/
Copy pathschema.ts
File metadata and controls
339 lines (326 loc) · 15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
/**
* Database Schema Definition
*
* This file defines all database tables and their structure using Drizzle ORM.
*
* Schema Overview:
* - users: User accounts (students, admins)
* - books: Library book catalog
* - borrowRecords: Book borrowing transactions
* - bookReviews: User reviews and ratings
* - adminRequests: Requests for admin privileges
* - systemConfig: Dynamic system configuration (fines, limits, etc.)
*
* Database: PostgreSQL (Hetzner VPS)
* ORM: Drizzle ORM
* Naming Convention: snake_case in database, camelCase in TypeScript
*/
import {
varchar,
uuid,
integer,
text,
pgTable,
date,
pgEnum,
timestamp,
boolean,
decimal,
jsonb,
} from "drizzle-orm/pg-core";
/**
* PostgreSQL Enums
*
* Enums ensure data integrity by restricting values to predefined options
*
* STATUS_ENUM: Used for admin requests and user account status
* ROLE_ENUM: User roles (USER or ADMIN)
* BORROW_STATUS_ENUM: Book borrowing status lifecycle
*/
export const STATUS_ENUM = pgEnum("status", [
"PENDING",
"APPROVED",
"REJECTED",
]);
export const ROLE_ENUM = pgEnum("role", ["USER", "ADMIN"]);
export const BORROW_STATUS_ENUM = pgEnum("borrow_status", [
"PENDING", // User requested to borrow, awaiting admin approval
"BORROWED", // Book is currently borrowed by user
"RETURNED", // Book has been returned
"CANCELLED", // Admin rejected the pending request (row kept for history)
]);
export const RESERVATION_STATUS_ENUM = pgEnum("reservation_status", [
"WAITING",
"READY",
"FULFILLED",
"CANCELLED",
"EXPIRED",
]);
/**
* Users Table
*
* Stores all user accounts (students and admins)
*
* Key Fields:
* - id: UUID primary key (auto-generated)
* - email: Unique identifier for login
* - password: versioned memory-hard hash; legacy salted SHA-256 is upgraded on login
* - status: Account approval status (PENDING/APPROVED/REJECTED)
* - role: User role (USER/ADMIN)
* - lastActivityDate: Last time user interacted with system
* - lastLogin: Last successful login timestamp
*/
export const users = pgTable("users", {
id: uuid("id").notNull().primaryKey().defaultRandom().unique(),
fullName: varchar("full_name", { length: 255 }).notNull(),
email: text("email").notNull().unique(), // Unique constraint ensures no duplicate emails
universityId: integer("university_id").notNull().unique(), // University student ID
password: text("password").notNull(), // Format: "salt:hash" (both base64 encoded)
universityCard: text("university_card").notNull(), // University card image/identifier
status: STATUS_ENUM("status").default("PENDING"), // New users start as PENDING
role: ROLE_ENUM("role").default("USER"), // Default role is USER (not admin)
lastActivityDate: date("last_activity_date").defaultNow(), // Tracks user engagement
lastLogin: timestamp("last_login", { withTimezone: true }), // Updated on each login
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), // Last account/permission change
updatedBy: text("updated_by"), // Server-derived actor email for permission/status auditability
// Durable signup APPROVED/REJECTED actor (UUID) — not overwritten by role-only edits
statusReviewedBy: uuid("status_reviewed_by"), // FK to users.id (see migration 0011)
statusReviewedAt: timestamp("status_reviewed_at", { withTimezone: true }),
createdAt: timestamp("created_at", {
withTimezone: true,
}).defaultNow(), // Account creation timestamp
});
/**
* User Status Decisions (signup approve/reject ledger)
*
* Append-only history for library registration decisions.
* Survives REJECTED → PENDING re-apply (unlike users.status_reviewed_* alone).
* Migration: 0012_user_status_decisions.sql
*/
export const userStatusDecisions = pgTable("user_status_decisions", {
id: uuid("id").notNull().primaryKey().defaultRandom().unique(),
userId: uuid("user_id")
.references(() => users.id, { onDelete: "cascade" })
.notNull(),
// Reuses status enum; application only writes APPROVED | REJECTED
decision: STATUS_ENUM("decision").notNull(),
decidedBy: uuid("decided_by").references(() => users.id, {
onDelete: "set null",
}),
decidedAt: timestamp("decided_at", { withTimezone: true })
.defaultNow()
.notNull(),
});
/**
* Books Table
*
* Stores the library catalog with all book information
*
* Inventory Management:
* - totalCopies: Total number of copies owned by library
* - availableCopies: Currently available copies (decremented when borrowed)
* - When availableCopies reaches 0, book cannot be borrowed
*
* Enhanced Fields (for better cataloging):
* - ISBN, publication year, publisher, language, page count, edition
* - These help with book identification and metadata
*/
export const books = pgTable("books", {
id: uuid("id").notNull().primaryKey().defaultRandom().unique(),
title: varchar("title", { length: 255 }).notNull(),
author: varchar("author", { length: 255 }).notNull(),
genre: text("genre").notNull(), // Category (e.g., "Programming", "Fiction")
rating: integer("rating").notNull(), // Average rating (1-5 stars)
coverUrl: text("cover_url").notNull(), // Book cover image URL
coverColor: varchar("cover_color", { length: 7 }).notNull(), // Hex color for placeholder
description: text("description").notNull(), // Book description/synopsis
totalCopies: integer("total_copies").notNull().default(1), // Total inventory
availableCopies: integer("available_copies").notNull().default(0), // Available to borrow
videoUrl: text("video_url").notNull(), // Book trailer or related video
summary: varchar("summary").notNull(), // Detailed summary
// Enhanced tracking and control fields
isbn: varchar("isbn", { length: 20 }), // International Standard Book Number
publicationYear: integer("publication_year"), // Year published
publisher: varchar("publisher", { length: 255 }), // Publishing company
language: varchar("language", { length: 50 }).default("English"), // Book language
pageCount: integer("page_count"), // Number of pages
edition: varchar("edition", { length: 50 }), // Edition number/version
isActive: boolean("is_active").default(true).notNull(), // Soft delete flag (catalog visibility)
// Curated homepage hero: at most one row should be true (enforced by partial unique index)
isFeatured: boolean("is_featured").default(false).notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), // Last modification
updatedBy: uuid("updated_by").references(() => users.id), // Who last updated (admin)
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), // When added to catalog
});
/**
* Borrow Records Table
*
* Tracks all book borrowing transactions and their lifecycle
*
* Status Flow:
* 1. PENDING: User requests to borrow → awaiting admin approval
* 2. BORROWED: Admin approves → book is borrowed, dueDate is set
* 3. RETURNED: User returns book → returnDate is set, fine calculated if overdue
*
* Fine Calculation:
* - Fine = (days overdue) × dailyFineAmount (from systemConfig)
* - Calculated when book is returned or updated via automation
* - Stored in fineAmount field for record keeping
*/
export const borrowRecords = pgTable("borrow_records", {
id: uuid("id").notNull().primaryKey().defaultRandom().unique(),
userId: uuid("user_id")
.references(() => users.id) // Foreign key to users table
.notNull(),
bookId: uuid("book_id")
.references(() => books.id) // Foreign key to books table
.notNull(),
borrowDate: timestamp("borrow_date", { withTimezone: true })
.defaultNow()
.notNull(), // When borrow request was created
dueDate: date("due_date"), // Nullable - set when admin approves (7 days from approval)
returnDate: date("return_date"), // When book was actually returned
status: BORROW_STATUS_ENUM("status").default("BORROWED").notNull(), // Current status
// Enhanced tracking and control fields
borrowedBy: text("borrowed_by"), // Who actually borrowed (email for readability, not UUID)
returnedBy: text("returned_by"), // Who returned the book (email for readability)
fineAmount: decimal("fine_amount", { precision: 10, scale: 2 }).default(
"0.00"
), // Late return fines (calculated on return or via automation)
notes: text("notes"), // Additional notes about the borrowing (admin notes, special conditions)
renewalCount: integer("renewal_count").default(0).notNull(), // How many times the book was renewed
lastReminderSent: timestamp("last_reminder_sent", { withTimezone: true }), // Track reminder notifications (prevents spam)
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), // Last modification
updatedBy: text("updated_by"), // Email for readability (who made the update)
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), // When record was created
});
// Parent: REQ-0030
export const reservations = pgTable("reservations", {
id: uuid("id").notNull().primaryKey().defaultRandom(),
userId: uuid("user_id").notNull().references(() => users.id),
bookId: uuid("book_id").notNull().references(() => books.id),
status: RESERVATION_STATUS_ENUM("status").notNull().default("WAITING"),
readyExpiresAt: timestamp("ready_expires_at", { withTimezone: true }),
fulfilledBorrowId: uuid("fulfilled_borrow_id").references(
() => borrowRecords.id,
),
updatedBy: text("updated_by"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
// Transactional outbox rows make READY notifications replay-safe. A delivery
// worker can mark deliveredAt without coupling provider calls to inventory locks.
export const reservationEvents = pgTable("reservation_events", {
id: uuid("id").notNull().primaryKey().defaultRandom(),
reservationId: uuid("reservation_id").notNull().references(() => reservations.id),
eventType: varchar("event_type", { length: 50 }).notNull(),
eventKey: varchar("event_key", { length: 100 }).notNull().unique(),
attemptCount: integer("attempt_count").notNull().default(0),
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }).notNull().defaultNow(),
lockedAt: timestamp("locked_at", { withTimezone: true }),
lastError: varchar("last_error", { length: 100 }),
provider: varchar("provider", { length: 30 }),
providerMessageId: varchar("provider_message_id", { length: 255 }),
deadLetteredAt: timestamp("dead_lettered_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
deliveredAt: timestamp("delivered_at", { withTimezone: true }),
});
// Client command IDs make circulation mutations safe to retry across network
// timeouts. The result is committed atomically with the domain mutation.
export const circulationCommands = pgTable("circulation_commands", {
id: uuid("id").notNull().primaryKey(),
actorId: uuid("actor_id").notNull().references(() => users.id),
operation: varchar("operation", { length: 50 }).notNull(),
entityId: uuid("entity_id").notNull(),
result: jsonb("result"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const operationTelemetry = pgTable("operation_telemetry", {
id: uuid("id").notNull().primaryKey().defaultRandom(),
operation: varchar("operation", { length: 80 }).notNull(),
kind: varchar("kind", { length: 20 }).notNull(),
outcome: varchar("outcome", { length: 20 }).notNull(),
durationMs: integer("duration_ms").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
/**
* System Configuration Table
*
* Stores dynamic system settings that can be changed without code deployment
*
* Common Keys:
* - "daily_fine_amount": Fine per day for overdue books (e.g., "1.00")
* - "borrow_duration_days": How many days users can borrow books (e.g., "7")
* - "max_renewals": Maximum number of times a book can be renewed (e.g., "2")
*
* Benefits:
* - Admins can adjust settings via UI without code changes
* - Settings are persisted in database
* - Audit trail via updatedBy and updatedAt
*/
export const systemConfig = pgTable("system_config", {
id: uuid("id").notNull().primaryKey().defaultRandom().unique(),
key: varchar("key", { length: 100 }).notNull().unique(), // Setting identifier (unique)
value: text("value").notNull(), // Setting value (stored as text, parsed as needed)
description: text("description"), // Human-readable description of what this setting does
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
updatedBy: text("updated_by"), // Email for readability (admin who changed it)
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
});
/**
* Book Reviews Table
*
* Stores user reviews and ratings for books
*
* Business Rules:
* - Users can only review books they have borrowed (enforced in API)
* - One review per user per book (enforced by unique constraint in application logic)
* - Rating must be 1-5 stars (validated in API)
*
* Used for:
* - Displaying book ratings on book pages
* - Helping other users decide which books to borrow
* - Calculating average book ratings
*/
export const bookReviews = pgTable("book_reviews", {
id: uuid("id").notNull().primaryKey().defaultRandom().unique(),
bookId: uuid("book_id")
.references(() => books.id) // Foreign key to books table
.notNull(),
userId: uuid("user_id")
.references(() => users.id) // Foreign key to users table
.notNull(),
rating: integer("rating").notNull(), // 1-5 stars (validated in API)
comment: text("comment").notNull(), // Review text content
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), // When review was posted
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), // When review was last edited
});
/**
* Admin Requests Table
*
* Tracks requests from users who want admin privileges
*
* Workflow:
* 1. User submits request with reason
* 2. Status = PENDING (awaiting admin review)
* 3. Admin reviews and either APPROVES or REJECTS
* 4. If approved, user's role is updated to ADMIN
* 5. If rejected, rejectionReason is stored for record keeping
*
* Security:
* - Only existing admins can approve/reject requests
* - All actions are logged (reviewedBy, reviewedAt)
*/
export const adminRequests = pgTable("admin_requests", {
id: uuid("id").notNull().primaryKey().defaultRandom().unique(),
userId: uuid("user_id")
.references(() => users.id) // Foreign key to users table
.notNull(),
requestReason: text("request_reason").notNull(), // Why they want admin access
status: STATUS_ENUM("status").default("PENDING").notNull(), // PENDING, APPROVED, REJECTED
reviewedBy: uuid("reviewed_by").references(() => users.id), // Admin who reviewed the request
reviewedAt: timestamp("reviewed_at", { withTimezone: true }), // When request was reviewed
rejectionReason: text("rejection_reason"), // Reason for rejection if applicable
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), // When request was submitted
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), // Last modification
});