fix(intake-links): three PocketBase quirks the schema shipped with

The schema the plan specified had three places where a PocketBase
quirk blocked normal use of intake_links / activity rows. None of
them surfaced in Task 1 because the unit-level test there only
checked rule application, not inserts.

1. intake_links.submission_count and submission_count_today were
   required: true with min: 0. PocketBase treats 0 as blank for
   required number fields, so every insert 400'd. Made optional.
2. intake_links.active was required: true, which rejects false as
   blank (the same quirk). The owner can mint an already-revoked
   link (the test exercises this); made optional.
3. activity.actor was a required relation. The public route writes
   a card_submitted_anonymously row for a submitter who has no user
   account; empty string is not a valid relation. Made optional and
   dropped actor: '' from the route.
This commit is contained in:
Dave 2026-09-21 06:30:49 +02:00
parent 3d8bc12ed8
commit c11d378483
2 changed files with 107 additions and 6 deletions

View file

@ -580,6 +580,95 @@ async function ensureCardsCreatedByRoleValues(pb: PocketBase): Promise<void> {
console.log(` + cards.created_by_role values: ${missing.join(", ")}`);
}
/**
* The intake_links schema first shipped with `submission_count` and
* `submission_count_today` as `required: true` and `min: 0`. PocketBase
* treats `0` as blank for required number fields, so any insert is rejected
* with `validation_required` until the field is marked optional. The
* counters default to 0 on creation; existing rows are unaffected.
*/
async function ensureIntakeLinkCountersOptional(pb: PocketBase): Promise<void> {
const links = await pb.collections.getOne("intake_links");
const fields = links.fields.map((f: { name: string; required?: boolean }) => {
if (
(f.name === "submission_count" || f.name === "submission_count_today") &&
f.required !== false
) {
return { ...f, required: false };
}
return f;
});
const changed = fields.some(
(f: { name: string; required?: boolean }, i: number) =>
f.required !== links.fields[i].required
);
if (!changed) {
console.log(" = intake_links submission_counters already optional");
return;
}
await pb.collections.update(links.id, { fields });
console.log(" + intake_links submission_counters now optional");
}
/**
* The activity row for an anonymous public submission has no actor by
* definition. The route omits the field rather than sending an empty
* string (which the relation would reject). Making `actor` optional in
* the schema lets the route write that row cleanly.
*/
async function ensureActivityActorOptional(pb: PocketBase): Promise<void> {
const activity = await pb.collections.getOne("activity");
const fields = activity.fields.map((f: { name: string; required?: boolean }) => {
if (f.name === "actor" && f.required !== false) {
return { ...f, required: false };
}
return f;
});
const changed = fields.some(
(f: { name: string; required?: boolean }, i: number) =>
f.required !== activity.fields[i].required
);
if (!changed) {
console.log(" = activity.actor already optional");
return;
}
await pb.collections.update(activity.id, { fields });
console.log(" + activity.actor now optional");
}
/**
* `active: false` fails the same PocketBase quirk as `0` does for number
* fields and `""` does for relations — `required: true` rejects the falsy
* value. The owner can create an already-revoked link, so the field has to
* accept both values at insert time. Every code path that creates a link
* sets `active: true` explicitly.
*/
async function ensureIntakeLinkActiveOptional(pb: PocketBase): Promise<void> {
const links = await pb.collections.getOne("intake_links");
const fields = links.fields.map((f: { name: string; required?: boolean }) => {
if (f.name === "active" && f.required !== false) {
return { ...f, required: false };
}
return f;
});
const changed = fields.some(
(f: { name: string; required?: boolean }, i: number) =>
f.required !== links.fields[i].required
);
if (!changed) {
console.log(" = intake_links.active already optional");
return;
}
await pb.collections.update(links.id, { fields });
console.log(" + intake_links.active now optional");
}
async function ensureProjectKindField(pb: PocketBase): Promise<void> {
const projects = await pb.collections.getOne("projects");
const have = new Set(projects.fields.map((f: { name: string }) => f.name));
@ -984,14 +1073,14 @@ async function main(): Promise<void> {
{
name: "submission_count",
type: "number",
required: true,
required: false,
onlyInt: true,
min: 0,
},
{
name: "submission_count_today",
type: "number",
required: true,
required: false,
onlyInt: true,
min: 0,
},
@ -1255,6 +1344,19 @@ async function main(): Promise<void> {
// Extend cards.created_by_role to include "anonymous" for the public route.
await ensureCardsCreatedByRoleValues(pb);
// PocketBase rejects inserts to intake_links unless the counter fields are
// optional — `required: true` + `min: 0` is contradictory there.
await ensureIntakeLinkCountersOptional(pb);
// The public route writes activity rows with no actor (the submitter is
// anonymous); the actor field must therefore be optional.
await ensureActivityActorOptional(pb);
// Same PocketBase quirk as submission_count: `required: true` on a bool
// field rejects `false` as blank. The owner can mint an already-revoked
// link, so the field has to be optional.
await ensureIntakeLinkActiveOptional(pb);
console.log("Done.");
}

View file

@ -125,10 +125,9 @@ export async function POST(request: Request): Promise<Response> {
await pb.collection("activity").create({
project: link.project,
card: card.id,
// Empty actor on purpose — the schema requires the field, but the submitter
// is anonymous by construction. The card detail page distinguishes
// anonymous submissions in the UI rather than via the actor field.
actor: "",
// No actor — the submitter is anonymous by construction. The card detail
// page distinguishes anonymous submissions via the
// card_submitted_anonymously action rather than the actor field.
action: "card_submitted_anonymously",
field: "",
old_value: "",