Skip to content

Implement #86 for auditing Late Cancellation Reason - #100

Open
Shamanbenny wants to merge 12 commits into
masterfrom
feat-1.2
Open

Implement #86 for auditing Late Cancellation Reason#100
Shamanbenny wants to merge 12 commits into
masterfrom
feat-1.2

Conversation

@Shamanbenny

@Shamanbenny Shamanbenny commented May 28, 2026

Copy link
Copy Markdown
Collaborator

Closes #86

2 1

This PR started as audit support for late cancellation reasons, but the branch now goes further and converts late-cancellation behavior from an hour-based cutoff to a reminder-driven workflow.

Summary

This PR now includes:

  • Admin user audit history for reviewing late cancellations.
  • Required late-cancellation reasons with UI and backend enforcement.
  • A reminder-driven late state: once a slot's reminder batch is sent, that slot is considered late.
  • Configurable weekly reminder settings in /admin/settings.
  • Reminder emails that include the participant roster.
  • Participant update emails when someone joins after reminders were sent, or when someone cancels late.
  • Admin protections that prevent changing a walk's date/start time after reminders were sent.
  • Reminder cron endpoint support for manual/external triggering.

What Changed

1. Admin user audit history

  • Added a per-user admin history page at /admin/users/[userId]/history.
  • Added entry points from the admin users list to review audit history.
  • Late cancellation audit entries now display reminder-based metadata, including reminder_sent_at.
  • History rendering was aligned to Singapore time handling used elsewhere in the app.

2. Late cancellation is now reminder-driven

Previously, late cancellation depended on app_settings.late_cancel_hours and walk start time.

This PR removes that model from application behavior. A walk is now considered "late" once reminders for that slot have been sent, tracked by walk_slots.reminder_sent_at.

Practical effect:

  • Before reminders are sent: users can cancel normally.
  • After reminders are sent: cancellation requires a reason and is audited as a late cancellation.
  • The UI warning and backend logic now both key off reminder_sent_at instead of an hours-before-start calculation.

3. Cancellation flow and audit enforcement

  • The cancellation confirmation dialog supports required textarea input for late cancellations.
  • Backend/RPC validation still acts as the source of truth.
  • Late cancellations write LATE_WALK_CANCELLATION audit rows with slot metadata.
  • Audit metadata now records reminder_sent_at instead of late_cancel_hours.

4. Configurable weekly reminder settings

The admin settings page now controls the reminder batch logic with:

  • reminder_send_weekday
  • reminder_send_time
  • reminder_window_start_offset_days
  • reminder_window_length_days

This allows admins to configure both:

  • when the weekly reminder batch becomes active, and
  • which walk-date range that batch covers.

Example supported behavior:

  • send every Wednesday at 1pm Singapore time
  • cover walks starting 2 days later
  • cover a 7-day range from that starting point

5. Reminder emails and participant update emails

The reminder endpoint now:

  • computes the active reminder window from admin settings
  • selects eligible walks in that window
  • sends reminder emails to active participants
  • includes the current participant roster in each reminder
  • sets walk_slots.reminder_sent_at once the slot has been processed

Additional post-reminder notifications were added:

  • If someone joins after reminders were already sent, all active participants receive a participant update email with the latest roster.
  • If someone cancels late, the remaining active participants receive a participant update email with the updated roster.

Current email subject lines:

  • Reminder: Upcoming Survey Walk
  • Notice: Walk Participant Update

Note: these emails are sent individually; no email-threading behavior is implemented in this PR.

6. Admin walk edit restrictions after reminders

Once reminder_sent_at is set for a walk slot:

  • admins can no longer change that slot's walk_date or start_time
  • the admin walks UI surfaces the late/reminded state

This protects the reminder roster and late-cancellation semantics after participant notifications have already gone out.

7. Reminder endpoint / triggering behavior

The reminder route remains:

  • GET /api/cron/reminders

Important operational note:

  • this PR provides the route and reminder-window logic
  • it does not provision an automatic scheduler
  • reminders must currently be triggered manually or by an external scheduler

The endpoint is protected by CRON_SECRET, and middleware was updated so this specific route is not redirected to /login before token validation.

Database Persistent Changes

This section covers the database changes made as a result of the PR.

Added Table: public.user_audit_logs

Purpose: expandable audit trail for user-related events. Issue 86 only writes late walk cancellations, but the shape supports future events such as invitations, slot registration/cancellation, admin role changes, account approvals/rejections, and similar history entries.

Columns:

Column Type Notes
id uuid Primary key, defaults to gen_random_uuid().
user_id uuid Subject user. Required. References public.profiles(id).
actor_id uuid User who performed the action. Nullable for system actions. References public.profiles(id).
event_type public.user_audit_event_type Event discriminator, currently LATE_WALK_CANCELLATION.
reason text Optional reason/comment. Required by application/RPC for late cancellations.
slot_id uuid Optional walk slot context. References public.walk_slots(id).
round_id uuid Optional survey round context. References public.survey_rounds(id).
metadata jsonb Flexible display/context payload. Defaults to {}.
occurred_at timestamptz Time the audited event occurred. Defaults to now().
created_at timestamptz Time the audit row was inserted. Defaults to now().

Indexes:

  • user_audit_logs_user_occurred_idx on (user_id, occurred_at desc) for the per-user history page.
  • user_audit_logs_event_type_idx on event_type.
  • user_audit_logs_actor_idx on actor_id.

RLS:

  • RLS is enabled.
  • Admin profiles may select audit logs.
  • No direct insert/update/delete policy is added; audit rows are written by database functions.

Added Enum: public.user_audit_event_type

Initial values:

  • LATE_WALK_CANCELLATION

Add future audit event values here before writing new event types into public.user_audit_logs.

Schema changes

  • public.app_settings

    • add reminder_send_weekday integer not null default 3
    • add reminder_send_time time not null default '13:00:00'
    • add reminder_window_start_offset_days integer not null default 2
    • add reminder_window_length_days integer not null default 7
    • remove late_cancel_hours
  • public.walk_slots

    • add reminder_sent_at timestamptz

Function change

Old signature:

public.cancel_slot_with_draft_cleanup(p_slot_id uuid)

New signature:

public.cancel_slot_with_draft_cleanup(
  p_slot_id uuid,
  p_cancellation_reason text default null
)

Original Function public.cancel_slot_with_draft_cleanup(p_slot_id uuid)

Live definition captured from:

select pg_get_functiondef('public.cancel_slot_with_draft_cleanup(uuid)'::regprocedure);
CREATE OR REPLACE FUNCTION public.cancel_slot_with_draft_cleanup(p_slot_id uuid)
 RETURNS jsonb
 LANGUAGE plpgsql
 SECURITY DEFINER
 SET search_path TO 'public'
AS $function$
declare
  v_user_id uuid := auth.uid();
  v_membership_id uuid;
  v_has_submitted boolean;
  v_draft_ids uuid[];
  v_file_paths text[];
begin
  if v_user_id is null then
    return jsonb_build_object('error', 'Not authenticated');
  end if;

  select id
  into v_membership_id
  from public.slot_memberships
  where slot_id = p_slot_id
    and user_id = v_user_id
    and status = 'ACTIVE'
  limit 1
  for update;

  if v_membership_id is null then
    return jsonb_build_object('error', 'You are not actively joined to this walk.');
  end if;

  select exists (
    select 1
    from public.observations
    where slot_id = p_slot_id
      and user_id = v_user_id
      and status = 'SUBMITTED'
  )
  into v_has_submitted;

  if v_has_submitted then
    return jsonb_build_object(
      'error',
      'You can''t cancel this walk after submitting your report.'
    );
  end if;

  select coalesce(array_agg(o.id), '{}'::uuid[])
  into v_draft_ids
  from public.observations o
  where o.slot_id = p_slot_id
    and o.user_id = v_user_id
    and o.status = 'DRAFT';

  select coalesce(array_agg(distinct m.file_path), '{}'::text[])
  into v_file_paths
  from public.media m
  where m.observation_id = any(v_draft_ids)
     or m.sighting_id in (
       select s.id
       from public.sightings s
       where s.observation_id = any(v_draft_ids)
     );

  update public.slot_memberships
  set status = 'CANCELLED',
      cancelled_at = now()
  where id = v_membership_id;

  delete from public.observations
  where id = any(v_draft_ids);

  return jsonb_build_object(
    'success', true,
    'deleted_draft_count', coalesce(array_length(v_draft_ids, 1), 0),
    'file_paths', v_file_paths
  );
end;
$function$

public.cancel_slot_with_draft_cleanup(uuid, text) now:

  • determines late cancellation from walk_slots.reminder_sent_at
  • requires a trimmed reason for late cancellations
  • records reminder_sent_at in audit metadata
  • preserves the existing draft cleanup behavior and JSON response contract

Notes / follow-up

  • Slots are now marked reminded even if they currently have no emailable participants, so the reminder batch still establishes the slot's late state.
  • Admin is currently blocked from editing date/start time for a survey walk after reminder email has been sent (Future implementation would allow admins to forcibly update these fields with a given warning and prompt confirmation, afterwhich a date/time change email will be forwarded to all current participants [NOT IDEAL FOR ADMINS TO DO THIS; but acts as failsafe])
  • Reminder email does NOT currently contain Phone Number/ Guardian Information just yet (PR Implement #86 for auditing Late Cancellation Reason #100 does not contain the code for that just yet; will implement after merging)
  • Automatic scheduling is intentionally left out of this PR and can be added later via Supabase scheduled jobs, or another external scheduler.

@Shamanbenny Shamanbenny self-assigned this May 28, 2026

@juneha1120 juneha1120 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks, this looks good overall.

I left one comment about the late-cancel state potentially becoming stale if the user keeps the page open across the cutoff. I think that’s the one thing worth fixing before merge.

Non-blocking:

  • package-lock.json changed even though package.json did not. If this was just local npm churn, it would be cleaner to revert the lockfile noise.
  • Using lateCancelWarning as both display text and business state is a bit stringly-typed. Passing structured cancellation state would be easier to maintain.

Comment thread app/(app)/walk/[walkId]/walk-detail-client.tsx Outdated

@zzawook zzawook left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks, this is close. I found a couple of things I think should be addressed before merge:

  1. app/(app)/walk/[walkId]/walk-detail-client.tsx: the late-cancel state can become stale if the page is loaded before the cutoff and the user cancels after the cutoff passes. The server correctly rejects the cancellation without a reason, but the dialog has no reason field, so the user cannot recover without refreshing. It would be safer to pass structured timing data, such as the slot start and late-cancel window, and compute the late-cancel state when opening/submitting the dialog.

  2. app/(app)/admin/users/[userId]/history/page.tsx: formatEventTime() uses the en-SG locale but does not set timeZone, so audit timestamps can render in the server timezone instead of Singapore time. Please set timeZone: 'Asia/Singapore' or reuse the shared app timezone constant.

Non-blocking:

  • package-lock.json changed without a corresponding package.json dependency change. If this is local npm churn, it would be cleaner to revert it.
  • Please confirm the required database changes are already applied/deployable for the shared Supabase backend, since this depends on user_audit_logs, the new enum, and the updated RPC signature.

Small caveat: I may be missing context on any of the above. If a comment is not fully correct, please let me know and feel free to ignore that part.

@Shamanbenny

Copy link
Copy Markdown
Collaborator Author

Thank you everyone for your code reviews/smoketests. I will get back into the flow of looking into the concerns you've raised once my internship settles for abit.

In the meantime, refer to #86 (comment) for the requirements clarification by Dr. Andie for the issues tackled by this PR.

@ngkhengyang ngkhengyang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think the direction is good and the creation of the audit log table may give way for potential features in the future which require logging specific user activities.

Following up with the requirements clarification, since it has now been stated that withdrawal before Wednesday notification, a late window (E.g. 24 hours before the walk) might not be as applicable since regardless of which day the walk takes place, the time when a withdrawal or cancellation is considered late seems to remain the same throughout for walks in upcoming Saturday to Friday (which is Wednesday).
Changing from a cancellation window of 24 hours to either a specific time (or late cancellation is only triggered based on a certain state after the reminder email has been sent) might be more applicable, but that may also be handled as a follow-up issue with PR if needed since this PR has successfully implemented the feature of late cancelling (along with user history/log auditing)

Comment thread app/(app)/walk/[walkId]/walk-detail-client.tsx Outdated
@Shamanbenny

Shamanbenny commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator Author

Reminder Email sent via Resend:

Reminder Upcoming Survey Walk Notice Walk Participant Update

To set it up for local use, create a Resend API Key and verify your domain (Yes, you need an actual domain that you own before you can use Resend). Next, update .env.local with the RESEND_API_KEY and RESEND_FROM_EMAIL to something like Primap <no-reply@resend.sneakyowl.net> where sneakyowl.net is replaced with your own domain.

Ensure npm run dev is running with CRON_SECRET=your_cron_secret for .env.local, and perform the following CLI command: curl -H "Authorization: Bearer your_cron_secret" http://localhost:3000/api/cron/reminders

@seanlim seanlim left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Found two possible issues and a few other clarifications. Everything else looks good to go after these are resolved! Nice work!

Comment thread app/api/cron/reminders/route.ts Outdated
Comment thread app/api/cron/reminders/route.ts Outdated
Comment thread lib/email.ts
Comment thread app/api/cron/reminders/route.ts Outdated
Comment thread app/api/cron/reminders/route.ts
Comment thread lib/admin-volunteer-analytics.ts Outdated
@ngkhengyang

ngkhengyang commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

I've helped to update the PR along with smoke testing. Here are the list of changes:

  • Fixed code issues raised by @seanlim
  • Updated display for walk slots in admin walk to display late cancellations and reasons
image
  • Fixed bug where if the walk detail page was loaded before the late-cancel cutoff and the user clicks Cancel Participation after the cutoff, no reasoning is requested and the cancellation is not considered late. Now the lateCancellationActive state is updated when the user clicks Cancel Participation
  • Add migration file to clean up old user_audit_log table data. Some old data still uses late_cancel_hours which references the old system of late cancellations being determined by number of hours before walk slot.
    • If user_audit_log.slot_id is null, the migration file attempts to resolve the slot data based on walk details provided in user_audit_log.metadata. If no resolved slot data could be found, the audit log row is deleted.
    • If the resolved slot does not have reminder_sent_at, it is assumed that reminder email has not been sent for that slot and hence there is no 'late cancellation', hence the auti log row is deleted.
    • The migration file and update on seed.sql aims to maintain consistent data format for user_audit_log entries under LATE_WALK_CANCELLATION event type.

@ngkhengyang ngkhengyang self-assigned this Aug 2, 2026
@ngkhengyang
ngkhengyang dismissed their stale review August 2, 2026 04:43

helping to work on this now

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1.2 Require a reason for late walk cancellations

5 participants