Skip to content

feat(invitations): multi-language CSV parsing, locale templates, and external ID conflict resolution#8427

Open
LWS49 wants to merge 2 commits into
masterfrom
lws49/feat-invite-header-validation
Open

feat(invitations): multi-language CSV parsing, locale templates, and external ID conflict resolution#8427
LWS49 wants to merge 2 commits into
masterfrom
lws49/feat-invite-header-validation

Conversation

@LWS49

@LWS49 LWS49 commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Rewrites the course invitation CSV pipeline across four areas. Header parsing is now order-independent and language-aware: English, Chinese, and Korean header rows are all accepted via a union alias map, removing the previous positional and English-only assumptions. Six locale-specific downloadable templates (en/zh/ko × timeline/no-timeline) replace the two English-only ones, with UTF-8 BOM so Excel opens zh/ko files without garbling the characters. Header validation is strict — the uploaded file must contain exactly the expected columns for the course's timeline setting, and duplicate columns (including cross-language duplicates like Name + 姓名) are now detected and rejected. External ID uploads to courses with existing members or pending invitations now go through a conflict resolution flow: the first pass defaults to keep-existing, and any file values that differ from stored are surfaced as an optional Apply action in the results dialog, with Undo. Finally, external ID is included in the user search across Manage Users and Student Statistics.

Design decisions

  • Union alias map accepts all three locales regardless of the uploader's UI locale - a CSV is a portable artifact that may be authored by a different person or before the uploader switched languages; the three languages' tokens are in disjoint character sets with zero collisions, so a union map is strictly more forgiving with no added complexity.
  • Six static templates + an RSpec guard test, not a generated endpoint - header terms are a stable contract; static files match the existing asset-download pattern; the guard test (asserting each template's header row parses cleanly) neutralises the only real risk of drift, making CI the source of truth.
  • Keep-existing on first pass, surface diffs as opt-in in results - conflicts only occur for repeat-uploaders on reset courses; gating the entire invite on an external ID decision front-loads cognition for a case that affects ~5% of uploads and none of the new ones. Deferring to the results table, where existing-member outcomes are already reported, keeps the common flow intact and makes the upsert an ignorable, reversible opt-in.
  • All-or-nothing Apply - the conflict set is small and cheap to rerun; partial apply would require per-row selection UI and a more complex backend, for a case (apply some diffs but not others from the same upload) that is contrived in practice. Undo + duplicate-conflict protection cover the cautious path.

Design Considerations

One combined diff section with sub-labels vs. two independent sections

Decision: Merge pending-update rows for invitations and enrolled members into a single "External ID updates" section with one atomic Apply covering both; distinguish them with sub-table headings rather than per-row badges or two independent Apply controls.
Why considered: Two visible tables naturally invite the expectation of two independent controls.
For:

  • The backend Apply is one transaction; the UI should match the single action rather than imply a granularity that does not exist.
  • Sub-labels reuse the dialog's existing invited-vs-enrolled section-header convention with no new per-row encoding.
    Against:
  • An admin cautious about rewriting active enrolled members' IDs (vs. pending invites) loses the ability to apply selectively.
    Alternatives:
  • Two tables, two independent Apply buttons - rejected; contradicts the atomic backend and adds a decision admins almost never need.
  • Per-row "Type" badge - rejected; its vocabulary ("Enrolled") doesn't match the section headers ("Course Users"), forcing a mapping that exists nowhere else.
    Rationale: I chose one combined section because the control granularity was already settled as atomic upstream. Matching the single action avoids implying a selectivity that the backend doesn't support, and sub-labels reuse the dialog's existing structural convention.

Accept all 3 locales regardless of UI locale

Decision: The parser accepts English, Chinese, and Korean header rows via one global union alias map, independent of I18n.locale.
Why considered: The obvious alternative is to accept only headers matching the user's current UI language.
For:

  • A CSV is a portable artifact - it may be authored by a different person, before the uploader switched languages, or be a file re-uploaded across courses.
  • The three languages' header tokens are in disjoint character sets with zero collisions, so the union map needs no locale branching.
  • Strictly more forgiving; can only make more files succeed, never fewer.
    Against:
  • Mixed-language files validate (treated as acceptable).
    Alternatives:
  • Gate acceptance on I18n.locale - rejected; produces brittle, surprising rejections with no upside and is more code.
    Rationale: I chose the union map because tying parse acceptance to UI locale solves no real problem and creates failure modes; the disjoint character sets mean there is no collision risk to mitigate.

Static per-language templates + guard test, not a generated endpoint

Decision: Ship 6 static CSV templates and add an RSpec guard asserting each template's header row is accepted by the parser.
Why considered: A backend-generated template would have a single source of truth between the template and the parser.
For:

  • Header terms are a stable, rarely-edited contract - exactly what static files serve well.
  • The guard test neutralises the only real weakness (drift): a stale template fails CI.
  • Matches the existing static-asset download pattern.
    Against:
  • Six small files to keep in canonical column order.
    Alternatives:
  • Client-generated blob - rejected; adds runtime machinery without removing the real cross-boundary duplication (client i18n must still mirror backend csv.yml).
  • Backend-generated endpoint - rejected; most infrastructure for a near-static artifact.
    Rationale: I chose static + guard test because the DRY benefit of generation was largely illusory; the client i18n and backend csv.yml would still need to stay in sync regardless. Static files with a CI guard give the safety of generation with none of the moving parts.

Regression prevention

Covers: multi-language header parsing (en/zh/ko accepted, mixed-language headers accepted), order-independent column resolution, exact-column validation (timeline vs. no-timeline mismatch rejected), duplicate column detection (same-language and cross-language), external ID conflict detection and keep-existing default, conflict resolution (apply and undo), external ID dedup across new invitations and existing records, re-invitation with external ID update, search by external ID in Manage Users and Student Statistics.

Manual testing: all scenarios above confirmed - zh/ko templates open without garbled characters in Excel, multi-language uploads parse correctly, duplicate-column uploads error with the offending column named, external ID conflict prompt appears and apply/undo work correctly for both invited and enrolled members, external ID search returns results.

@LWS49 LWS49 force-pushed the lws49/feat-invite-header-validation branch from 37a5204 to 211f960 Compare June 3, 2026 08:45
@LWS49 LWS49 changed the title Lws49/feat invite header validation feat(invitations): CSV header validation and ext ID conflict resolution Jun 3, 2026
@LWS49 LWS49 force-pushed the lws49/feat-invite-header-validation branch from 211f960 to 67de552 Compare June 3, 2026 09:38
@LWS49 LWS49 changed the title feat(invitations): CSV header validation and ext ID conflict resolution feat(invitations): add external ID field, CSV header validation, and multi-language parsing Jun 4, 2026
@LWS49 LWS49 force-pushed the lws49/feat-invite-header-validation branch from 1314602 to 121b8a7 Compare June 4, 2026 07:09
@LWS49 LWS49 force-pushed the lws49/feat-ext-id-invite-flow branch 3 times, most recently from 23c59ca to 559264e Compare June 4, 2026 15:16
@LWS49 LWS49 force-pushed the lws49/feat-ext-id-invite-flow branch from 546aed9 to b379882 Compare June 4, 2026 15:24
@LWS49 LWS49 force-pushed the lws49/feat-invite-header-validation branch from 121b8a7 to b8b6ea2 Compare June 4, 2026 15:51
@LWS49 LWS49 force-pushed the lws49/feat-ext-id-invite-flow branch 3 times, most recently from b761414 to db5b8c8 Compare June 7, 2026 13:15
@LWS49 LWS49 force-pushed the lws49/feat-invite-header-validation branch from b8b6ea2 to 41090fe Compare June 8, 2026 10:33
Base automatically changed from lws49/feat-ext-id-invite-flow to master June 8, 2026 10:40
@LWS49 LWS49 force-pushed the lws49/feat-invite-header-validation branch 3 times, most recently from d2db7b1 to fd1f9ca Compare June 8, 2026 11:33
@LWS49 LWS49 marked this pull request as ready for review June 8, 2026 11:42
@LWS49 LWS49 changed the base branch from master to lws49/feat-ext-id-search-update June 8, 2026 11:42
@LWS49 LWS49 force-pushed the lws49/feat-invite-header-validation branch 7 times, most recently from 6a0574f to e89d94b Compare June 9, 2026 01:52
Base automatically changed from lws49/feat-ext-id-search-update to master June 9, 2026 02:17
@LWS49 LWS49 force-pushed the lws49/feat-invite-header-validation branch from e89d94b to 9cfbad4 Compare June 9, 2026 02:24
@LWS49 LWS49 marked this pull request as draft June 9, 2026 03:13
@LWS49 LWS49 force-pushed the lws49/feat-invite-header-validation branch 2 times, most recently from d56c8f3 to 03143e6 Compare June 9, 2026 03:21
… resolution

- validate CSV column headers explicitly instead of checking column count
- detect external ID changes on existing users/invitations before applying;
  surface a confirmation prompt (Keep Existing / Replace) with a side-by-side
  Current / New External ID table capped at 320px for large uploads
- conflict resolution wired into both file upload and individual invite form;
  file ref preserved on Go Back so admin need not re-select
- add ExternalIdResolution and PendingExternalIdConflict types; update invite
  API and operations layer to detect and return conflict payload
- controller rescues PendingExternalIdUpdates, renders jbuilder partial;
  concern branches on @resolution to populate pending vs updated arrays
- i18n: add EN/KO/ZH translations for conflict prompt and new table columns
@LWS49 LWS49 force-pushed the lws49/feat-invite-header-validation branch 2 times, most recently from 180a6af to 49f1663 Compare June 9, 2026 04:28
@LWS49 LWS49 changed the title feat(invitations): add external ID field, CSV header validation, and multi-language parsing feat(invitations): multi-language CSV parsing, locale templates, and external ID conflict resolution Jun 9, 2026
@LWS49 LWS49 force-pushed the lws49/feat-invite-header-validation branch 9 times, most recently from 77c57cc to cca3bcd Compare June 9, 2026 07:38
…anonical column order, and locale-specific CSV templates
@LWS49 LWS49 force-pushed the lws49/feat-invite-header-validation branch from cca3bcd to d38fa7f Compare June 9, 2026 07:54
@LWS49 LWS49 marked this pull request as ready for review June 9, 2026 08:39
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 participant