Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,40 @@ Access control isn't handled in one place - it's layered, and each layer exists

Only three files in the entire `src/app` tree are `"use client"` at the page/layout level (`global-error.tsx`, the dashboard `error.tsx`, and the Sentry example page) - all three are boundaries Next.js or Sentry require to be client components. Everything else that needs interactivity is a small client component imported _into_ a server page, not the page itself.

## The ordering workflow

Parts ordering is the largest feature, and it splits deliberately between what a
member does and what an officer does.

**Members submit items only.** The submission form asks for vendor, link, item
name, part number, quantity, cost and notes - no fund type and no STF bucket.
One form can carry many items (see the repeatable-rows section in
[Forms & Validation](/tech-stack/forms-validation/)), and `POST /api/orders`
writes them all in one request, stamping a shared `batchId` when there is more
than one. Orders therefore land with `fundType`, `stfBucketId` and `quarterId`
all null.

**Officers triage in batches.** `/admin/orders` groups pending orders into
"Needs triage" (no fund yet) and "Ready to review" (assigned). Officers
multi-select rows and act on the whole selection:

| Endpoint | What it does |
| ------------------------------ | ---------------------------------------------------------------- |
| `POST /api/orders/assign` | Sets fund type, bucket and active quarter on the selected orders |
| `POST /api/orders/bulk-action` | Approves or denies the selected orders |

**Balance checks run at assignment and approval, never at submission.** A member
has no bucket to check against, and a balance checked at submit time is stale by
the time an officer reviews it. Two consequences worth knowing:

- Approving a selection checks each fund/bucket group's total _at once_
(`validateBatchBalance`). Checking orders one at a time would let a batch
overdraw a bucket that each individual order fits inside.
- Assigning checks the selection _plus everything already queued against that
bucket_ (`validateAssignmentBalance`). Pending orders don't reduce a bucket's
remaining balance - only approved and ordered ones do - so without this an
officer could quietly park more in a bucket than could ever be approved.

## Where each concern is documented

| Concern | Page |
Expand Down
25 changes: 12 additions & 13 deletions docs/tech-stack/database.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,18 @@ description: How the schema, migrations, seeding, and query patterns work.

Tables are defined with Drizzle's `sqliteTable()`. This file re-exports the auth tables from `src/lib/db/auth-schema.ts` (see below) and defines the app-specific ones:

| Table | Represents |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `team` | The club's FRC subteams (Arm, Autonomous, Chassis, etc.) |
| `stfQuarter` / `stfBucket` | School-year funding periods and their budget buckets ("STF" = Student Tech Fund), each with a starting balance in cents |
| `giftFund` / `financeSettings` | Singleton rows (fixed `id`) tracking the gift-fund balance and tax/shipping percentages |
| `orders` | Part purchase requests - vendor, link, item, cost, `status` (pending/approved/denied/ordered), fund type (STF/Gift) |
| `giftFundLog`, `orderHistory` | Audit trails for balance changes and order status transitions |
| `apiKey` | One-way hashed service API keys for the external simulation-script API (distinct from the vault - see [Security](/tech-stack/security/)) |
| `vaultEntry` / `vaultEntryAccess` | The shared credential vault and its per-user read grants |
| `minecraftWhitelist` | Minecraft whitelist requests (username, status, admin review) |
| `networkJoinRequest` | Tailscale/network device join requests |
| `userFeature` | Per-user feature-flag grant requests (pending/granted/rejected) - what `middleware.ts` checks for non-admin routes |
| `simExportCache` | On-disk cache index for generated OnShape simulation export archives, keyed on document/workspace/element ID - see [External Integrations](/tech-stack/external-integrations/#onshape-simulation-export-proxy) |
| Table | Represents |
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `team` | The club's FRC subteams (Arm, Autonomous, Chassis, etc.) |
| `stfQuarter` / `stfBucket` | School-year funding periods and their budget buckets ("STF" = Student Tech Fund), each with a starting balance in cents |
| `giftFund` / `financeSettings` | Singleton rows (fixed `id`) tracking the gift-fund balance and tax/shipping percentages |
| `orders` | Part purchase requests - vendor, link, item, cost, `status` (pending/approved/denied/ordered), fund type (STF/Gift, null until triaged), `batchId` grouping a multi-item submission |
| `giftFundLog`, `orderHistory` | Audit trails for balance changes and order status transitions |
| `apiKey` | One-way hashed service API keys for the external simulation-script API (distinct from the vault - see [Security](/tech-stack/security/)) |
| `vaultEntry` / `vaultEntryAccess` | The shared credential vault and its per-user read grants |
| `minecraftWhitelist` | Minecraft whitelist requests (username, status, admin review) |
| `networkJoinRequest` | Tailscale/network device join requests |
| `userFeature` | Per-user feature-flag grant requests (pending/granted/rejected) - what `middleware.ts` checks for non-admin routes |

Money is always stored as integer cents (`unitCostCents`, `startingBalanceCents`, etc.), never floats - this avoids floating-point rounding errors in financial arithmetic. Timestamps are stored as millisecond integers via a shared SQL fragment:

Expand Down
76 changes: 50 additions & 26 deletions docs/tech-stack/forms-validation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,36 +15,36 @@ None of these are Next.js-specific - this is a general pattern for any React app

Every form-heavy component (`OrderForm`, `LoginForm`, `SettingsForm`, `JoinRequestForm`, `WhitelistRequestForm`, `VaultEntryDialog`) follows the same shape. Using `src/components/orders/OrderForm.tsx` as the concrete example:

**1. Define a Zod schema at the top of the file**, including cross-field rules via `.superRefine()`:
**1. Define a Zod schema at the top of the file.** `OrderForm` submits a list of
items, so its schema wraps a per-item schema in an array:

```ts
const formSchema = z
.object({
fundType: z.enum(["STF", "Gift"], { message: "Select a fund type" }),
stfBucketId: z.string().optional(),
vendor: z.string().min(1, "Vendor is required").max(200),
link: z.string().url("Enter a valid URL").max(500),
quantity: z
.string()
.min(1, "Required")
.regex(/^\d+$/, "Whole number")
.refine((v) => Number(v) >= 1 && Number(v) <= 9999, "Between 1 and 9999"),
// ...
})
.superRefine((data, ctx) => {
if (data.fundType === "STF" && !data.stfBucketId) {
ctx.addIssue({
code: "custom",
message: "Select an STF bucket",
path: ["stfBucketId"],
});
}
});
const itemSchema = z.object({
vendor: z.string().min(1, "Vendor is required").max(200),
link: z.string().url("Enter a valid URL").max(500),
quantity: z
.string()
.min(1, "Required")
.regex(/^\d+$/, "Whole number")
.refine((v) => Number(v) >= 1 && Number(v) <= 9999, "Between 1 and 9999"),
// ...
});

const formSchema = z.object({
items: z.array(itemSchema).min(1).max(MAX_ORDER_BATCH_ITEMS),
});

type FormValues = z.infer<typeof formSchema>;
```

`.superRefine()` is what handles validation that depends on _more than one field at once_ - here, "an STF bucket is required, but only if fundType is STF." A field-level `z.string().min(1)` can't express that; `superRefine` can add an issue to any field's `path` based on the whole object.
Errors are reported per row, at paths like `items.2.link`, so one bad row in a
batch highlights that row rather than failing the whole form opaquely.

When validation depends on _more than one field at once_, use `.superRefine()`,
which can attach an issue to any field's `path` based on the whole object. A
field-level `z.string().min(1)` can't express that. `orderAssignSchema` in
`src/lib/validation.ts` is the current example: an STF assignment requires a
bucket, a Gift assignment does not.

**2. Wire the schema into `useForm`:**

Expand Down Expand Up @@ -79,7 +79,10 @@ const form = useForm<FormValues>({
async function onSubmit(values: FormValues) {
setSubmitting(true);
try {
const res = await fetch("/api/orders", { method: "POST", body: JSON.stringify(values) });
const res = await fetch("/api/orders", {
method: "POST",
body: JSON.stringify({ items: values.items }),
});
if (!res.ok) throw new Error(await res.text());
toast.success("Order submitted");
router.push("/orders");
Expand All @@ -97,4 +100,25 @@ Notice `quantity` and `unitCost` are `z.string()` with a `.regex()`/`.refine()`

## Where else this pattern shows up

If you're adding a new form, don't design a new pattern - copy the shape from the closest existing example: `src/components/auth/LoginForm.tsx` (simple, no cross-field validation), or `src/components/orders/OrderForm.tsx` (cross-field validation via `superRefine`, edit-vs-create mode via an optional `initialOrder` prop).
If you're adding a new form, don't design a new pattern - copy the shape from the closest existing example: `src/components/auth/LoginForm.tsx` (simple, single-object form), or `src/components/orders/OrderForm.tsx` (repeatable rows via `useFieldArray`, edit-vs-create mode via an optional `initialOrder` prop).

## Repeatable rows

`OrderForm` lets a member submit many items at once. It uses react-hook-form's
`useFieldArray` over the `items` array, rendering one set of fields per row:

```ts
const { fields, append, remove } = useFieldArray({ control: form.control, name: "items" });
```

Two rules keep this from feeling heavier than a single-item form:

- A lone row renders as a plain form, with no card chrome, index heading or
remove button. The batch affordances only appear once there is more than one
row, so the common case is unchanged.
- Field names are template paths (`` `items.${index}.vendor` ``), which is what
lets Zod's per-row issue paths line up with the right input.

`PasteItemsPanel` is the bulk entry path: it parses tab- or comma-separated rows
pasted from a spreadsheet into item objects and appends them all at once. Its
parser is pure and unit-tested in `src/components/orders/paste-items.test.ts`.
34 changes: 34 additions & 0 deletions drizzle/migrations/0012_smiling_loa.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_orders` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` text NOT NULL,
`fund_type` text,
`stf_bucket_id` integer,
`batch_id` text,
`assigned_by` text,
`assigned_at` integer,
`quarter_id` integer,
`vendor` text NOT NULL,
`link` text NOT NULL,
`item_name` text NOT NULL,
`part_number` text,
`quantity` integer DEFAULT 1 NOT NULL,
`unit_cost_cents` integer NOT NULL,
`notes` text,
`status` text DEFAULT 'pending' NOT NULL,
`denial_comment` text,
`reviewed_by` text,
`reviewed_at` integer,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`stf_bucket_id`) REFERENCES `stf_bucket`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`assigned_by`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`quarter_id`) REFERENCES `stf_quarter`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`reviewed_by`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
INSERT INTO `__new_orders`("id", "user_id", "fund_type", "stf_bucket_id", "batch_id", "assigned_by", "assigned_at", "quarter_id", "vendor", "link", "item_name", "part_number", "quantity", "unit_cost_cents", "notes", "status", "denial_comment", "reviewed_by", "reviewed_at", "created_at") SELECT "id", "user_id", "fund_type", "stf_bucket_id", NULL, NULL, NULL, "quarter_id", "vendor", "link", "item_name", "part_number", "quantity", "unit_cost_cents", "notes", "status", "denial_comment", "reviewed_by", "reviewed_at", "created_at" FROM `orders`;--> statement-breakpoint
DROP TABLE `orders`;--> statement-breakpoint
ALTER TABLE `__new_orders` RENAME TO `orders`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE INDEX `orders_batch_id_idx` ON `orders` (`batch_id`);
2 changes: 1 addition & 1 deletion drizzle/migrations/meta/0011_snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"version": "6",
"dialect": "sqlite",
"id": "e1fe9584-4c06-4391-9802-c7b4bd100d23",
"prevId": "8e304c55-9e95-4399-94dc-e6aa2727175c",
"prevId": "a2f3d812-5c71-4b88-a063-9f1e2d3c4b5a",
"tables": {
"api_key": {
"name": "api_key",
Expand Down
Loading