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
46 changes: 46 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Tests
#
# Manually-dispatched CI workflow that runs Vitest and the TypeScript
# type-check on demand. Trigger from the GitHub Actions UI
# (Actions → Tests → Run workflow) or `gh workflow run tests.yml` after
# pushing changes you want to verify against the full v0.4 regression
# surface (~2520 tests across the Cloudflare Workers vitest pool).
#
# Manual trigger is deliberate: the test suite is too heavy to run on
# every push during active development, and the local laptop can't
# host it. Run this when a milestone or risky phase wants cross-cutting
# verification, not on every commit.
#
# Concurrency is keyed by branch ref so a second manual dispatch on the
# same branch cancels the in-flight run rather than queueing.
#
# Runner: Ubicloud (`ubicloud-standard-4`, 4 vCPU / 16 GB) — comfortable
# headroom for the Workers vitest pool. Requires the Ubicloud GitHub
# App installed on the account; without it, runs queue indefinitely.
# Bump to `ubicloud-standard-8` if pool cold-starts begin pushing past
# the timeout.
#
# @version v0.4.1

name: Tests

on:
workflow_dispatch:

concurrency:
group: tests-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
runs-on: ubicloud-standard-4
timeout-minutes: 15
Comment on lines +27 to +37

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deliberate, and documented in the workflow header. It's manual-dispatch only, so it never blocks fork PR checks. We picked ubicloud-standard-4 (16 GB) for headroom on the full Workers vitest pool, which is heavier than a standard runner is sized for. If fork-friendliness comes up, we'd add a dispatch input defaulting to Ubicloud with ubuntu-latest as an override.

steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx vitest --run
- run: npm run typecheck
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

## [0.4.1] - 2026-05-29

### Fixed

- **Saving test and calibration entries.** Entries marked as the `test_images`
type could not be saved: autosave stalled and the manual "Save now" button
could not recover the entry, even though the type was selectable in the
outline. Save validation now accepts every valid entry type.

### Changed

- **Controlled vocabularies consolidated.** The catalogue's controlled
vocabularies — entry types, resource types, project roles, descriptive
standards, quality-control flag types, and volume statuses — are now defined
in a single place and shared across the database schema, validators, and type
definitions, removing the hand-copied duplicates that caused the save fault
above. An automated test now fails the build if these definitions drift apart.

## [0.4.0] - 2026-05-18

### Added
Expand Down
4 changes: 2 additions & 2 deletions app/components/layout/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@
* keeps the gap between the two clusters click-through so users can still
* interact with anything below.
*
* @version v0.4.0
* @version v0.4.1
*/

const VERSION = "0.4.0";
const VERSION = "0.4.1";

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — bumped package.json to 0.4.1 in 4f3f791.


export function Footer() {
return (
Expand Down
5 changes: 3 additions & 2 deletions app/components/qc-flags/qc-flag-card-expandable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@ import { ChevronDown } from "lucide-react";
import { QcFlagCard, type QcFlagCardData, type QcStatus } from "./qc-flag-card";
import { CommentThread } from "../comments/comment-thread";
import type { CommentWithAuthor } from "../../lib/description-types";
import type { ProjectRole } from "../../lib/validation/enums";

export type QCFlagCardExpandableProps = {
flag: QcFlagCardData;
volumeId: string;
comments: CommentWithAuthor[];
userRole: "lead" | "cataloguer" | "reviewer";
userRole: ProjectRole;
onResolveClick?: (flagId: string) => void;
onCommentAdded?: () => void;
};
Expand All @@ -34,7 +35,7 @@ export type QCFlagCardExpandableProps = {
* body calls this helper to decide whether to forward `onResolveClick`.
*/
export function shouldForwardResolve(
userRole: "lead" | "cataloguer" | "reviewer",
userRole: ProjectRole,
flagStatus: QcStatus,
): boolean {
return userRole === "lead" && flagStatus === "open";
Expand Down
3 changes: 2 additions & 1 deletion app/components/qc-flags/resolve-qc-flag-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useState, useEffect } from "react";
import { useFetcher } from "react-router";
import { useTranslation } from "react-i18next";
import { CheckCircle2, X } from "lucide-react";
import type { ProjectRole } from "../../lib/validation/enums";

export type QcStatus = "resolved" | "wontfix";

Expand Down Expand Up @@ -55,7 +56,7 @@ export type ResolveQcFlagDialogProps = {
open: boolean;
onClose: () => void;
flagId: string;
userRole: "lead" | "cataloguer" | "reviewer";
userRole: ProjectRole;
onResolved?: () => void;
onError?: (message: string) => void;
};
Expand Down
28 changes: 18 additions & 10 deletions app/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@
* left in the database for migration purity and should be treated as
* legacy for any future schema work.
*
* @version v0.4.0
* @version v0.4.1
*/

import {
Expand All @@ -99,6 +99,14 @@ import {
ENTITY_ROLES,
PLACE_ROLES,
VOCABULARY_STATUSES,
VOLUME_STATUSES,
ENTRY_TYPES,
RESOURCE_TYPES_ES,
PROJECT_ROLES,
DESCRIPTIVE_STANDARDS,
QC_PROBLEM_TYPES,
QC_RESOLUTION_ACTIONS,
GEONAMES_FCLASSES,
} from "../lib/validation/enums";
// AUDIT_LOG_ACTIONS lives in a tiny constants module so
// `app/lib/audit.server.ts` (which imports the auditLog table from
Expand All @@ -115,7 +123,7 @@ export const tenants = sqliteTable(
slug: text("slug").notNull().unique(),
name: text("name").notNull(),
kind: text("kind", { enum: ["tenant", "platform"] }).notNull().default("tenant"),
descriptiveStandard: text("descriptive_standard", { enum: ["isadg", "dacs", "rad"] }),
descriptiveStandard: text("descriptive_standard", { enum: [...DESCRIPTIVE_STANDARDS] }),
status: text("status", { enum: ["active", "suspended"] }).notNull().default("active"),
crowdsourcingEnabled: integer("crowdsourcing_enabled", { mode: "boolean" }).notNull().default(false),
vocabularyHubEnabled: integer("vocabulary_hub_enabled", { mode: "boolean" }).notNull().default(true),
Expand Down Expand Up @@ -315,7 +323,7 @@ export const projectMembers = sqliteTable(
id: text("id").primaryKey(),
projectId: text("project_id").notNull().references(() => projects.id),
userId: text("user_id").notNull().references(() => users.id),
role: text("role", { enum: ["lead", "cataloguer", "reviewer"] }).notNull(),
role: text("role", { enum: [...PROJECT_ROLES] }).notNull(),
createdAt: integer("created_at").notNull(),
},
(table) => [
Expand Down Expand Up @@ -349,7 +357,7 @@ export const volumes = sqliteTable(
manifestUrl: text("manifest_url").notNull(),
pageCount: integer("page_count").notNull(),
status: text("status", {
enum: ["unstarted", "in_progress", "segmented", "sent_back", "reviewed", "approved"],
enum: [...VOLUME_STATUSES],
})
.notNull()
.default("unstarted"),
Expand Down Expand Up @@ -399,7 +407,7 @@ export const entries = sqliteTable(
endPage: integer("end_page"), // explicit for children, null for top-level
endY: real("end_y"), // fraction 0-1, null for top-level
type: text("type", {
enum: ["item", "blank", "front_matter", "back_matter", "test_images"],
enum: [...ENTRY_TYPES],
}), // nullable: unset by default
// Per-project document subtype label (e.g. "Escritura", "Poder", or a
// free-typed "OTRO" value). Only meaningful when `type = 'item'`; null
Expand All @@ -418,7 +426,7 @@ export const entries = sqliteTable(
// Description metadata fields
translatedTitle: text("translated_title"),
resourceType: text("resource_type", {
enum: ["texto", "imagen", "cartografico", "mixto"],
enum: [...RESOURCE_TYPES_ES],
}),
dateExpression: text("date_expression"),
dateStart: text("date_start"),
Expand Down Expand Up @@ -488,14 +496,14 @@ export const qcFlags = sqliteTable(
.references(() => volumePages.id, { onDelete: "cascade" }),
reportedBy: text("reported_by").notNull().references(() => users.id),
problemType: text("problem_type", {
enum: ["damaged", "repeated", "out_of_order", "missing", "blank", "other"],
enum: [...QC_PROBLEM_TYPES],
}).notNull(),
description: text("description").notNull(),
status: text("status", { enum: ["open", "resolved", "wontfix"] })
.notNull()
.default("open"),
resolutionAction: text("resolution_action", {
enum: ["retake_requested", "reordered", "marked_duplicate", "ignored", "other"],
enum: [...QC_RESOLUTION_ACTIONS],
}),
resolverNote: text("resolver_note"),
resolvedBy: text("resolved_by").references(() => users.id),
Expand Down Expand Up @@ -536,7 +544,7 @@ export const comments = sqliteTable(
parentId: text("parent_id"), // null = top-level, references comments.id for nesting
authorId: text("author_id").notNull().references(() => users.id),
authorRole: text("author_role", {
enum: ["cataloguer", "reviewer", "lead"],
enum: [...PROJECT_ROLES],
}).notNull(),
text: text("text").notNull(),
createdAt: integer("created_at").notNull(),
Expand Down Expand Up @@ -812,7 +820,7 @@ export const places = sqliteTable(
// column. The DB-level CHECK on fclass
// (`IS NULL OR IN ('P','H','A','T','S')`) lives in the migration;
// Drizzle's `enum:` hint here is the TypeScript-side mirror.
fclass: text("fclass", { enum: ["P", "H", "A", "T", "S"] }),
fclass: text("fclass", { enum: [...GEONAMES_FCLASSES] }),
legacyIds: text("legacy_ids").notNull().default("[]"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
Expand Down
15 changes: 8 additions & 7 deletions app/lib/boundary-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,16 @@
* the SaveStatus pill component, so `app/lib/` stays unit-testable
* without dragging in the component layer.
*
* @version v0.4.0
* @version v0.4.1
*/

export type EntryType =
| "item"
| "blank"
| "front_matter"
| "back_matter"
| "test_images";
// EntryType is derived from the canonical `ENTRY_TYPES` array
// (app/lib/validation/enums.ts) so the segmentation type vocabulary has
// a single source of truth shared with the Drizzle schema and the save
// validator. Imported locally for use below and re-exported so the many
// modules that import `EntryType` from here keep working.
import type { EntryType } from "./validation/enums";
export type { EntryType };

export type Entry = {
id: string;
Expand Down
5 changes: 3 additions & 2 deletions app/lib/description-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*/

import type { DescriptionStatus } from "./description-workflow";
import type { ResourceTypeEs, ProjectRole } from "./validation/enums";

/**
* Entry fields relevant to the description form context.
Expand All @@ -22,7 +23,7 @@ export type DescriptionEntry = {
endPage: number | null;
title: string | null;
translatedTitle: string | null;
resourceType: "texto" | "imagen" | "cartografico" | "mixto" | null;
resourceType: ResourceTypeEs | null;
dateExpression: string | null;
dateStart: string | null;
dateEnd: string | null;
Expand Down Expand Up @@ -60,7 +61,7 @@ export type Comment = {
regionH: number | null;
parentId: string | null;
authorId: string;
authorRole: "cataloguer" | "reviewer" | "lead";
authorRole: ProjectRole;
text: string;
createdAt: number;
updatedAt: number;
Expand Down
5 changes: 3 additions & 2 deletions app/lib/description.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,13 @@ import {
import type { WorkflowRole } from "./workflow";
import { logActivity } from "./workflow.server";
import { createComment } from "./comments.server";
import { RESOURCE_TYPES_ES, type ResourceTypeEs } from "./validation/enums";

// --- Validation schema for submit-for-review ---

const submitSchema = z.object({
title: z.string().min(1, "Title is required"),
resourceType: z.enum(["texto", "imagen", "cartografico", "mixto"]),
resourceType: z.enum(RESOURCE_TYPES_ES),
dateExpression: z.string().min(1, "Date expression is required"),
scopeContent: z.string().min(1, "Scope and content is required"),
language: z.string().min(1, "Language is required"),
Expand All @@ -62,7 +63,7 @@ const submitSchema = z.object({
export type DescriptionFields = {
title?: string | null;
translatedTitle?: string | null;
resourceType?: "texto" | "imagen" | "cartografico" | "mixto" | null;
resourceType?: ResourceTypeEs | null;
dateExpression?: string | null;
dateStart?: string | null;
dateEnd?: string | null;
Expand Down
5 changes: 3 additions & 2 deletions app/lib/entries.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@
* so a malformed payload fails fast with a useful message rather than
* surfacing as a CHECK violation deep in the batch.
*
* @version v0.4.0
* @version v0.4.1
*/
import { eq } from "drizzle-orm";
import type { DrizzleD1Database } from "drizzle-orm/d1";
import { entries } from "../db/schema";
import type { Entry } from "./boundary-types";
import { ENTRY_TYPES } from "./validation/enums";

/**
* Load all entries for a volume, ordered by position.
Expand Down Expand Up @@ -238,7 +239,7 @@ function validateEntries(entriesToSave: Entry[], volumeId: string): void {
throw new Error("endY must be a number between 0 and 1, or null");
}
}
if (entry.type !== null && !["item", "blank", "front_matter", "back_matter"].includes(entry.type)) {
if (entry.type !== null && !(ENTRY_TYPES as readonly string[]).includes(entry.type)) {
throw new Error(`invalid entry type: ${entry.type}`);
}
}
Expand Down
5 changes: 3 additions & 2 deletions app/lib/invites.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
sendExistingUserInviteEmail,
} from "./email.server";
import { getAppConfig } from "./config.server";
import type { ProjectRole } from "./validation/enums";

type InviterUser = {
id: string;
Expand Down Expand Up @@ -108,7 +109,7 @@ export async function createInvite(
id: crypto.randomUUID(),
projectId,
userId: existingUser.id,
role: role as "lead" | "cataloguer" | "reviewer",
role: role as ProjectRole,
createdAt: now,
});
}
Expand Down Expand Up @@ -253,7 +254,7 @@ export async function acceptInvite(
id: crypto.randomUUID(),
projectId: invite.projectId,
userId: user.id,
role: role as "lead" | "cataloguer" | "reviewer",
role: role as ProjectRole,
createdAt: now,
});
}
Expand Down
3 changes: 2 additions & 1 deletion app/lib/operator-actions.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@

import { z } from "zod";
import { SlugSchema } from "./tenant";
import { DESCRIPTIVE_STANDARDS } from "./validation/enums";

/**
* Boolean coercion for HTML form inputs. A checkbox sends its `value`
Expand Down Expand Up @@ -106,7 +107,7 @@ const checkboxSchema = z
export const CreateTenantSchema = z.object({
slug: SlugSchema,
name: z.string().min(1, { message: "Name is required" }).max(120),
descriptiveStandard: z.enum(["isadg", "dacs", "rad"]),
descriptiveStandard: z.enum(DESCRIPTIVE_STANDARDS),
crowdsourcingEnabled: checkboxSchema.default(false),
vocabularyHubEnabled: checkboxSchema.default(true),
publishPipelineEnabled: checkboxSchema.default(true),
Expand Down
5 changes: 3 additions & 2 deletions app/lib/permissions.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
// --- TEMPLATE INFRASTRUCTURE --- do not modify when extending

import { eq, and } from "drizzle-orm";
import { PROJECT_ROLES } from "./validation/enums";
import type { DrizzleD1Database } from "drizzle-orm/d1";
import { projectMembers, entries, volumes, volumePages } from "../db/schema";
import type { DescriptionStatus } from "./description-workflow";
Expand Down Expand Up @@ -202,7 +203,7 @@ export async function requireEntryAccess(
db,
userId,
volume.projectId,
["lead", "cataloguer", "reviewer"],
[...PROJECT_ROLES],
isAdmin
);

Expand Down Expand Up @@ -351,7 +352,7 @@ export async function requirePageAccess(
db,
userId,
volume.projectId,
["lead", "cataloguer", "reviewer"],
[...PROJECT_ROLES],
isAdmin
);

Expand Down
Loading
Loading