https://github.com/help-14/linkz
https://github.com/help-14/linkz
Last synced: 22 days ago
JSON representation
- Host: GitHub
- URL: https://github.com/help-14/linkz
- Owner: help-14
- Created: 2026-05-24T03:30:45.000Z (28 days ago)
- Default Branch: main
- Last Pushed: 2026-05-24T15:07:57.000Z (28 days ago)
- Last Synced: 2026-05-24T17:12:04.744Z (28 days ago)
- Language: Svelte
- Size: 75.2 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
Awesome Lists containing this project
README
# linkz — Seat Reservation Platform
A public seat reservation demo built for the Linkz Senior Engineer technical assessment. Displays available seats, authenticates users, processes payments via Stripe Checkout, and finalizes reservations upon payment confirmation.
## Quick start
```sh
# 1. Start Postgres
docker compose up -d
# 2. Install dependencies
pnpm install
# 3. Configure environment
cp .env.example .env
# Edit .env with your Stripe test key and a random BETTER_AUTH_SECRET
# 4. Run migrations and seed data
pnpm run seed
# 5. Start dev server
pnpm run dev
```
The seed script creates one user (`a@a.vn` / `test`) and 4 seats (A, B, C available; D reserved).
## User flow
1. **Register / Login** — email/password auth with 90-day session expiry
2. **Browse seats** — see live availability and pricing
3. **Select a seat** — review details and start payment
4. **Pay via Stripe** — redirected to Stripe-hosted Checkout (test mode)
5. **Confirmation** — payment verified server-side, reservation finalized
## Architecture
### Stack
| Layer | Choice | Rationale |
| --------- | --------------------------------------------- | ------------------------------------------------------------------------------------ |
| Framework | SvelteKit 5 | Familiarity, file-based routing, isomorphic rendering |
| Database | PostgreSQL 16 | Industry standard for transactional workloads, row-level locking, strong consistency |
| ORM | Drizzle ORM | Type-safe query builder, native BetterAuth adapter, lightweight |
| Auth | BetterAuth | Feature-complete (sessions, email/password, CSRF), SvelteKit adapter |
| Payments | Stripe Checkout | Hosted UI eliminates PCI scope, test mode enables local dev |
| Cache | Redis | Industry standard |
| Styling | Tailwind CSS 4 + shadcn-svelte | Utility-first, maintains design consistency with minimal custom CSS |
| Infra | Docker Compose (local), adapter-auto (deploy) | Zero-config local dev, flexible deployment target |
### Project structure
```
src/
├── app.html # HTML shell
├── app.d.ts # App.Locals type augmentation
├── hooks.server.ts # Session hydration per-request
├── lib/
│ ├── auth-client.ts # BetterAuth browser client
│ ├── utils.ts # cn() utility
│ ├── components/ui/ # shadcn-svelte UI primitives
│ └── server/
│ ├── auth/auth.ts # BetterAuth server instance
│ ├── db/
│ │ ├── index.ts # Drizzle + postgres clients
│ │ └── schema.ts # user, session, account, seats, payments
│ └── stripe/index.ts # Stripe SDK init
└── routes/
├── +layout.svelte # Nav header, session display, sign out
├── layout.css # Tailwind + theme variables
├── (homepage)/ # Landing page
├── login/ # Email/password sign in
├── register/ # Email/password sign up
├── reserve/
│ ├── +page.svelte # Seat grid selection
│ └── [seatId]/
│ ├── +page.svelte # Routes to ReservationForm / Invoice / UnavailableState
│ ├── ReservationForm.svelte
│ ├── Invoice.svelte
│ └── UnavailableState.svelte
└── payment/
├── create/+server.ts # POST handler — reserves seat, creates Stripe session
└── success/+page.server.ts # Verifies payment, finalizes reservation
```
### Database schema
**`user`** — BetterAuth-managed users with email + password hash.
**`seats`** — Seats with `status` enum (`available | pending | reserved`). The `pending` state temporarily locks a seat during payment (15 min expiry) to prevent double-booking.
**`payments`** — Payment records tracking the lifecycle (`pending → completed | failed | expired`). Each payment links a user to a seat at a point in time.
### Reservation flow (detailed)
```
User clicks "Pay with Stripe"
│
▼
POST /payment/create
├─ Check seat availability (non-transactional pre-check)
├─ BEGIN transaction
│ ├─ Check for existing pending payment (same user + seat)
│ ├─ UPDATE seats SET status='pending', reserved_by=user.id
│ │ WHERE (status='available' OR status='pending' AND reserved_by=user.id)
│ │ └─ returns 0 rows → seat taken → rollback → redirect with error
│ └─ INSERT INTO payments (status='pending')
└─ COMMIT
├─ stripe.checkout.sessions.create()
│ └─ on failure: mark payment 'failed', release seat, redirect with error
└─ Redirect to Stripe Checkout URL
│
▼ (user completes payment on Stripe)
│
▼
GET /payment/success?session_id=...
├─ stripe.checkout.sessions.retrieve(sessionId)
├─ Validate:
│ ├─ payment_status === 'paid'
│ ├─ metadata.userId matches authenticated user
│ └─ client_reference_id references a valid pending payment
├─ BEGIN transaction
│ ├─ UPDATE payments SET status='completed', completed_at=NOW()
│ └─ UPDATE seats SET status='reserved'
└─ Display invoice
```
## Design decisions and trade-offs
### Microservices vs monolith
For the sake of this test, I'll use monolith architecture, but it is still support horizontal scaling by running multiple instant of this app connected to the same database and cache (eg Redis) to share the session data, it is production safe and ready.
For real world project, I'll use microservices architecture and split this into auth service (backend), payment service(backend), booking service(backend) and svelte frontend service. Each team can work on there own service.
Microservices architecture comes with a few trade off:
- Massive development and operational complexity: Development time increase massively, took more time to setup workable service. Increase latency when using service to service call.
- Increase operational cost: K8s, AWS ECS is expensive, network call still costly.
### Local authentication flow vs identity provider
Regarding authentication, I agree that for a production-scale business, using a managed identity provider such as Firebase Auth or Clerk is often the better strategic choice. In this project, I used Better Auth mainly to demonstrate, my intention was not necessarily to suggest storing credentials internally is always the best production decision. Using dedicated provider does reduce operational and security burden unless we have some custom login flow that we need to support like intergration with local goverment services. Better-Auth itself is very safe and mature so it is a good option to manage authentication flow.
I'm using session cookie for this demo, but to add JWT for mobile is easy with Better Auth: https://better-auth.com/docs/plugins/jwt
### Webhook-free payment confirmation
Stripe webhooks are the production-standard approach for payment confirmation. This project deliberately avoids them for local development simplicity — instead, it verifies the session on the redirect-back page using `stripe.checkout.sessions.retrieve()`. This introduces a synchronous dependency on the user completing the redirect, which means:
- **Trade-off**: If the user closes their browser before the redirect completes, the payment may go through but the seat stays `pending` until the 15-min expiry.
- **Mitigation**: Add Stripe webhooks + a background job that reconciles stale pending seats (see [Known limitations](#known-limitations)).
- **Why acceptable here**: Test-mode assessment; webhooks require a public endpoint or tunnel (ngrok) which adds setup friction.
### Optimistic seat locking with 15-min expiry
Seats transition `available → pending → reserved` rather than going directly from `available → reserved`:
- **Trade-off**: Adds complexity (expiry checks, timer display on UI) versus atomic reserve.
- **Why worth it**: Prevents double-booking during the payment window. Without pending state, two users could both reach Stripe Checkout for the same seat, both pay, and the second would need a refund. The pending lock reduces this window to near-zero.
### Database-level locking (optimistic via WHERE clause)
The seat reservation uses `UPDATE ... WHERE (status = 'available')` inside a transaction rather than `SELECT ... FOR UPDATE`:
- **Trade-off**: If two concurrent requests target the same seat, only one `UPDATE`'s `returning` clause returns a row. The loser's transaction commits but the affected row count is 0.
- **Why acceptable**: Lighter-weight than explicit row locks. For the expected concurrency (3 seats, low traffic), this is sufficient. At scale, `SELECT ... FOR UPDATE` or advisory locks would be preferred.
### Why not Drizzle for payment queries
The payment module uses raw `postgres` tagged template literals alongside Drizzle ORM:
- **Trade-off**: Inconsistent API surface, type-unsafe `as` casts.
- **Why accepted**: Drizzle's transaction API (`db.transaction`) was [historically limited](https://github.com/drizzle-team/drizzle-orm/issues) for raw SQL interop at the time of writing. The `postgres` client provides a more expressive transaction API with direct row materialization. A production version would standardize on one approach.
## Security considerations
| Concern | Status | Notes |
| ------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Password hashing | ✓ | BetterAuth uses bcrypt with cost factor 10 |
| Session expiry | ✓ | 90 days as required |
| CSRF | Partial | SvelteKit checks Origin/Referer headers for POST requests; no CSRF token on /payment/create |
| Rate limiting | ✗ | No protection on login or payment endpoints — should be added before production |
| Price manipulation | ✓ | Seat price read from DB at checkout time, not from client input |
| Payment ownership | ✓ | Stripe metadata.userId verified against authenticated session |
| Secrets in source | ✓ | Credentials are environment-injected |
| Hardcoded dev credentials | ⚠️ | Seed user `a@a.vn` / `test` is a dev-only convenience; should use env-configurable credentials or require first-user setup in production |
## Operational concerns
### Monitoring & observability
The application emits [OpenTelemetry](https://opentelemetry.io/) traces for all SvelteKit lifecycle points (`handle`, `load`, form actions, remote functions) via `src/instrumentation.server.ts`. Traces are exported over OTLP/HTTP to a local [Jaeger](https://www.jaegertracing.io/) instance.
**Start the stack** (Jaeger is included in `docker-compose.yaml`):
```sh
docker compose up -d
```
**View traces**: http://localhost:16686 — select service `linkz`.
The OTLP endpoint defaults to `http://localhost:4318`. Override via env:
```env
OTEL_EXPORTER_OTLP_ENDPOINT="http://your-collector:4318"
```
In a production environment, also add:
1. **Health endpoint** — `GET /health` returning DB connectivity, Stripe API reachability
2. **Structured logging** — structured logger (pino/winston) capturing request IDs, error context, payment lifecycle events
3. **Error tracking** — integration with Sentry or similar for unhandled rejections and Stripe API errors
### Background job needed — stale payment expiry
A pg-boss worker now runs the background payment cleanup/reconciliation path. It should be started in a separate terminal with `pnpm worker`.
The worker currently handles:
- expiring stale pending payments every minute
- releasing seats held by expired pending payments
- retrying Stripe session reconciliation if the user never returns from Checkout or the success-page verification hits a transient Stripe error
The expiry job performs the equivalent of:
```sql
UPDATE payments SET status = 'expired'
WHERE status = 'pending' AND expires_at < NOW();
UPDATE seats SET status = 'available', reserved_by = NULL, reserved_at = NULL
WHERE id IN (
SELECT seat_id FROM payments WHERE status = 'expired'
);
```
The inline expiry check on `reserve/[seatId]` still exists as a fallback, but the worker is now the primary cleanup mechanism.
### Database migrations
Drizzle Kit manages schema evolution via SQL migration files in `drizzle/`. The current migrations are:
- `0000` — Initial schema (user, session, seats without price_cents, payments)
- `0001` — BetterAuth accounts table
- `0002` — Session metadata (ipAddress, userAgent)
- `0003` — Added price_cents to seats
**Note**: Migration `0000` creates the `seats` table without a `price_cents` column, which is added later in `0003`. A production migration would be squashed into a single initial migration for cleanliness.
## Improvements for production
### Critical path
- **Stripe webhooks** — Replace redirect-based verification with idempotent webhook handling for reliability
- **Rate limiting** — Apply per-IP rate limits on auth and payment endpoints
- **Background seat expiry** — Dedicated job to release stale pending seats
### Data integrity
- **Store price at purchase time** — Add `price_cents` to the `payments` table so invoices are immutable
- **Unique constraint on seat labels** — Prevent duplicate seat names in the same venue
- **Composite unique on pending payments** — Ensure at most one pending payment per user per seat at the DB level
- **Standardize on Drizzle ORM** — Eliminate raw SQL to improve type safety and maintainability
### UX
- **Loading states** — Skeleton/shimmer UI during page transitions and payment verification
- **Auto-polling for seat status** — Poll seat availability while viewing a pending seat
- **Mobile breakpoints** — The seat grid works on all screen sizes but the layout could use more polish at narrow widths
### Operations
- **Health checks** — `/health` endpoint for load balancer / orchestrator
- **Structured logging** — Request-scoped logging with correlation IDs
- **Graceful Stripe API version management** — Extract the API version to an env variable or pin it explicitly in a comment
## Stripe test mode
Set your test key in `.env`:
```env
STRIPE_SECRET_KEY=sk_test_...
```
Use Stripe test card `4242 4242 4242 4242` with any future expiry and any CVC.
List of test cards: https://docs.stripe.com/testing
Run the worker in a second terminal during local development:
```sh
pnpm worker
```
## Environment reference
```env
DATABASE_URL="postgres://user:password@localhost:6432/linkz"
BETTER_AUTH_URL=http://localhost:5173
BETTER_AUTH_SECRET="generate with: openssl rand -base64 32"
STRIPE_SECRET_KEY="sk_test_..."
OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318" # optional, defaults to this value
```