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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ All notable changes to OpenSignup are documented here. Format follows [Keep a Ch
- SMTP sends reuse their connections (#333). nodemailer used to connect, set up TLS and log in for every email, so a busy reminder run opened one connection per reminder, which was slower and could hit a mail provider's connections-per-minute limit (a `421` reply, then a retried job). The SMTP transport now keeps a pool of up to 3 connections open between emails, each replaced after 100 messages; the web server and the worker each keep their own. A connection the mail server closes while idle is dropped, and the next email opens a new one. An email whose connection closes while it is being sent is tried once more on another connection, then fails as before. The connect timeouts, the STARTTLS requirement and the refusal of file paths and URLs as content apply to every pooled connection as they did to each single one. Console and Resend are unchanged.

### Fixed
- On the public sign-up page, a slot that starts within the signup's `lockoutHoursBeforeSlot` now shows as Closed, with no **Sign up** button or spots-left count (#513). The page used to offer it, and every Confirm was refused with "No one new can sign up for this slot now. Reload the page to see what’s still open.", and reloading showed the same page. The page and the sign-up service now ask one rule, `slotInLockout`. No screen sets the lockout; `PATCH /api/signups/[id]` and the connector's `update_signup` tool do. The organizer's preview and `GET /api/public/signups/[slug]` show such a slot as `closed` too.
- **Duplicate** on the Build tab now copies a slot that has a number in a Number field (#512). It used to add nothing and show `"qty" must be a number`, because the copy sent the number as text. Every slot the Build tab adds now sends a Number field's value as a number, as editing a cell already did, and leaves an empty one out.
- A participant name of only spaces is now refused, on signing up and on the edit page, with `invalid_input` on `name` (#515). Signing up used to store it blank, so the Responses tab, the CSV export and the confirmation email ("Thanks , you signed up…") showed no name, and the edit page said "Saved." while keeping the old name; only a move refused it. The sign-up dialog and the edit page now say "Enter your name, up to 100 characters." instead of the validator's own text. The organizer's display name in signup settings follows the same rule. Names already stored blank stay as they are.
- Creating or updating a signup now refuses a `groupByFieldRefs` entry that isn't one of the signup's field refs, with `invalid_input` on `settings.groupByFieldRefs` and the message "groupByFieldRefs must name one of the signup's fields" (#516). `POST /api/signups`, `PATCH /api/signups/[id]` and the AI connector's `update_signup` used to save it and report success, while the public page quietly showed the slots ungrouped, and adding a field with that ref later turned grouping on with nobody asking. An update is checked only when it sends `groupByFieldRefs`, so a signup already holding such a ref still saves its other settings.
Expand Down
8 changes: 7 additions & 1 deletion src/app/s/[slug]/public-signup.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { slotTimeOfDay } from '@/lib/reminder-fields';
import { slotInLockout } from '@/lib/signup-closing';
import { requiresEmail, sendsReminders } from '@/schemas/signups';
import type { SlotFieldDefinition } from '@/schemas/slot-fields';
import type { SlotStatus } from '@/schemas/slots';
Expand All @@ -24,10 +25,13 @@ interface SourceSignup {
* and full field definitions, not just its slots: whether a slot has a time
* (`hasTime`) depends on which time field pairs with the reminder date field,
* and the view's own fields carry neither the reminder ref nor sort orders.
* A slot inside the signup's lockout before it starts (`slotInLockout`) is
* shown closed, since the service refuses a sign-up for it.
*/
export function toSignupViewSlots(
signup: SourceSignup,
committedBySlot?: Record<string, number>,
now: number = Date.now(),
): SignupViewSlot[] {
const settings = (signup.settings ?? {}) as { reminderFromFieldRef?: string };
return signup.slots.map((slot) => {
Expand All @@ -39,7 +43,9 @@ export function toSignupViewSlots(
slotAt: slot.slotAt ? slot.slotAt.toISOString() : null,
hasTime: slot.slotAt !== null && slotTimeOfDay(settings, signup.fields, values) !== null,
capacity: slot.capacity,
status: slot.status as SlotStatus,
status: slotInLockout(signup.settings, slot.slotAt, now)
? 'closed'
: (slot.status as SlotStatus),
committed: committedBySlot?.[slot.id] ?? 0,
};
});
Expand Down
40 changes: 40 additions & 0 deletions src/app/s/[slug]/signup-view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,46 @@ describe('the live page past its closing time', () => {
});
});

describe('the live page inside the lockout before a slot', () => {
const NOW = Date.parse('2030-04-06T12:00:00Z');
const HOUR = 3600 * 1000;
const slot = (id: string, team: string, slotAt: number) => ({
id,
ref: id,
values: { team },
slotAt: new Date(slotAt),
capacity: 2,
status: 'open',
});
const SIGNUP_ROW = {
settings: { lockoutHoursBeforeSlot: 24 },
fields: [] as SlotFieldDefinition[],
slots: [slot('soon', 'Hawks', NOW + 2 * HOUR), slot('later', 'Owls', NOW + 72 * HOUR)],
};

it('shows a slot that starts within the lockout as closed, with no Sign up button or count', () => {
render(
<SignupViewBody
signup={SIGNUP}
fields={[{ ref: 'team', label: 'Team', fieldType: 'text' }]}
groupByRef={null}
slots={toSignupViewSlots(SIGNUP_ROW, {}, NOW)}
slug="example"
mode="live"
/>,
);
expect(screen.queryByRole('button', { name: /^Sign up for Hawks/ })).toBeNull();
expect(screen.getByRole('button', { name: /^Sign up for Owls/ })).toBeEnabled();
expect(screen.getAllByText('Closed', { selector: 'span' })).toHaveLength(1);
expect(screen.getAllByText('2 left')).toHaveLength(1);
});

it('closes no slot when the signup sets no lockout', () => {
const slots = toSignupViewSlots({ ...SIGNUP_ROW, settings: {} }, {}, NOW);
expect(slots.map((s) => s.status)).toEqual(['open', 'open']);
});
});

describe('<SignupViewBody /> for a returning participant', () => {
const OWN = [{ slotId: 's1', editUrl: '/s/example/c/com_1?t=tok', participantName: 'Pat' }];

Expand Down
25 changes: 24 additions & 1 deletion src/lib/signup-closing.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { shownSignupStatus, signupShutReason } from './signup-closing';
import { shownSignupStatus, signupShutReason, slotInLockout } from './signup-closing';

const NOW = Date.parse('2030-04-06T12:00:00Z');
const PAST = new Date(NOW - 60_000);
Expand Down Expand Up @@ -36,3 +36,26 @@ describe('shownSignupStatus', () => {
expect(shownSignupStatus({ status: 'archived', closesAt: PAST }, NOW)).toBe('archived');
});
});

describe('slotInLockout', () => {
const HOUR = 3600 * 1000;
const LOCKOUT = { lockoutHoursBeforeSlot: 24 };

it('is in the lockout once the slot starts within that many hours', () => {
expect(slotInLockout(LOCKOUT, new Date(NOW + 2 * HOUR), NOW)).toBe(true);
expect(slotInLockout(LOCKOUT, new Date(NOW - HOUR), NOW)).toBe(true);
});

it('is not in it while the slot is further away than that', () => {
expect(slotInLockout(LOCKOUT, new Date(NOW + 25 * HOUR), NOW)).toBe(false);
});

it('never locks out with no lockout set, a zero lockout, or a slot with no date', () => {
const soon = new Date(NOW + HOUR);
expect(slotInLockout({}, soon, NOW)).toBe(false);
expect(slotInLockout(null, soon, NOW)).toBe(false);
expect(slotInLockout({ lockoutHoursBeforeSlot: 0 }, soon, NOW)).toBe(false);
expect(slotInLockout({ lockoutHoursBeforeSlot: null }, soon, NOW)).toBe(false);
expect(slotInLockout(LOCKOUT, null, NOW)).toBe(false);
});
});
20 changes: 20 additions & 0 deletions src/lib/signup-closing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,23 @@ export function signupShutReason(
export function shownSignupStatus(signup: ClosingSignup, now: number = Date.now()): string {
return signupShutReason(signup, now) === 'closes_at' ? 'closed' : signup.status;
}

/**
* Whether a slot is inside the signup's `lockoutHoursBeforeSlot`: it starts
* within that many hours, so it takes no more sign-ups. The slot-level half of
* `whyNotTakingPlaces` that the public page shares (`toSignupViewSlots`), so
* the page shows Closed where the service would refuse. Counted back from
* `slot_at`, so a date-only slot is measured from its noon-UTC anchor rather
* than from midnight (see `extractSlotAt`). No UI sets the lockout today; the
* API and the MCP `update_signup` tool do.
*/
export function slotInLockout(
settings: unknown,
slotAt: Date | null,
now: number = Date.now(),
): boolean {
const hours =
(settings as { lockoutHoursBeforeSlot?: number | null } | null)?.lockoutHoursBeforeSlot ?? 0;
if (!slotAt || hours <= 0) return false;
return now > slotAt.getTime() - hours * 3600 * 1000;
}
27 changes: 11 additions & 16 deletions src/services/commitments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { serviceError, ServiceException, type ServiceError } from '@/lib/errors'
import { makeId } from '@/lib/ids';
import { log } from '@/lib/log';
import { summarizeSlotValues } from '@/lib/slot-summary';
import { signupShutReason } from '@/lib/signup-closing';
import { signupShutReason, slotInLockout } from '@/lib/signup-closing';
import { parseInputSafe } from '@/lib/parse';
import { requireWorkspaceWrite, type Actor } from '@/lib/policy';
import { toPublicCommitment, type PublicCommitment } from '@/lib/public-commitment';
Expand Down Expand Up @@ -134,9 +134,10 @@ async function safeRecordAttemptFailed(
* the order signing up has always checked them: the slot is closed; the
* signup's status is anything but `open` (a draft, closed or archived signup);
* its `closesAt` has passed; or the slot starts within the signup's
* `lockoutHoursBeforeSlot`. The two signup rules are `signupShutReason`, which
* the public page reads too. Taking a place (`commitParticipantToSlot`, for
* someone signing up and for a move to another slot), raising a quantity
* `lockoutHoursBeforeSlot`. The two signup rules are `signupShutReason` and the
* lockout is `slotInLockout`, which the public page reads too. Taking a place
* (`commitParticipantToSlot`, for someone signing up and for a move to another
* slot), raising a quantity
* (`updateOwnCommitment`) and the edit page's limit (`editLimitsForCommitment`)
* all ask this, so a place refused one way cannot be had another; so does the
* edit page of a cancelled sign-up (`cancelledCommitmentState`), before it
Expand Down Expand Up @@ -173,18 +174,12 @@ function whyNotTakingPlaces(
error: serviceError('closed', 'signup has closed'),
};
}
// Counted back from slot_at, so for a date-only slot it is measured from the
// noon-UTC anchor rather than from midnight (see extractSlotAt). No UI sets
// lockoutHoursBeforeSlot today.
const lockoutHours = signup.settings.lockoutHoursBeforeSlot ?? 0;
if (slot.slotAt && lockoutHours > 0) {
if (Date.now() > slot.slotAt.getTime() - lockoutHours * 3600 * 1000) {
return {
reason: 'over_window',
detail: 'slot_lockout',
error: serviceError('closed', 'too close to the slot time to sign up'),
};
}
if (slotInLockout(signup.settings, slot.slotAt)) {
return {
reason: 'over_window',
detail: 'slot_lockout',
error: serviceError('closed', 'too close to the slot time to sign up'),
};
}
return null;
}
Expand Down
Loading