Add a dedicated bulk import endpoint, separate from the single-record write endpoints (#16).
Import is a distinct operation: it validates a batch, detects duplicates, resolves field-level conflicts, and commits atomically — none of which map cleanly onto repeated POST /api/v1/quotes calls.
Endpoint
POST /api/v1/quotes/import
Request
{
"format": "json" | "csv",
"enrich": false,
"conflictStrategy": "skip" | "overwrite" | "review",
"data": "<raw JSON array or CSV string>"
}
Alternatively, multipart/form-data with a file upload — both should be supported.
conflictStrategy
Controls what happens when an imported record matches an existing record (same quote + source text) but has different field values.
| Strategy |
Behaviour |
skip |
Keep existing record unchanged. Default — safe for automated imports. |
overwrite |
Replace all differing fields with imported values, regardless of what is currently stored. |
review |
Do not write conflicting records. Return the conflicts in the response for the user to resolve manually (see conflict resolution flow below). |
A record with no field differences is always silently skipped — it is identical to what already exists.
enrich
Optional (default false). When true, runs the enrichment service (#19) on each successfully imported row after writing.
Response
{
"imported": 10,
"skipped": 2,
"enriched": 7,
"errors": [
{ "row": 4, "field": "type", "message": "Unknown type 'videogame'" }
],
"conflicts": [
{
"importId": "client-assigned-id-or-row-index",
"existingId": "uuid-of-existing-record",
"fields": [
{ "field": "date", "existingValue": "1994", "importedValue": "1995" },
{ "field": "genres", "existingValue": ["drama"], "importedValue": ["drama", "thriller"] }
]
}
]
}
conflicts is populated only when conflictStrategy: "review". Each conflict includes enough information for the UI to render a side-by-side comparison.
Conflict resolution endpoint
POST /api/v1/quotes/import/resolve
Accepts per-field resolutions for conflicts returned by a prior review import. The user decides each field independently.
{
"resolutions": [
{
"existingId": "uuid",
"fields": [
{ "field": "date", "resolution": "keep" | "replace" },
{ "field": "genres", "resolution": "keep" | "replace" }
]
}
]
}
Each resolution is recorded in the audit log (#56) with ActorType='user', Action='updated', and old/new values — giving a clear record of who decided what and why.
General behaviour
- IDs always generated server-side — never trusted from the payload
createdAt and updatedAt set to time of import — source timestamps ignored
- Valid rows committed in a single transaction per batch; one bad row does not abort the batch
- Unknown fields in the payload silently ignored
originalLanguage defaults to "en" if absent
- When
enrich: true, enrichment runs after commit; a failed lookup leaves the row as-is without rolling back the import
Formats supported
- JSON — array of objects in the canonical
quotes.json schema
- CSV — columns:
quote, source, author, character, type, genres, date, originalLanguage
Existing sources as the format reference
The two seed datasets (vilaboim/movie-quotes and NikhilNamal17/popular-movie-quotes) conform to the JSON schema after seed script normalisation. The import endpoint must accept that output directly.
Enrichment integration
Shared with the enrichment script (#19) via Quotinator.Enrichment. The import endpoint calls the same service when enrich: true. Design decision on library vs. project resolved in #19.
Auth
Requires API key authentication (#15) — same policy as write endpoints.
Add a dedicated bulk import endpoint, separate from the single-record write endpoints (#16).
Import is a distinct operation: it validates a batch, detects duplicates, resolves field-level conflicts, and commits atomically — none of which map cleanly onto repeated
POST /api/v1/quotescalls.Endpoint
POST /api/v1/quotes/importRequest
{ "format": "json" | "csv", "enrich": false, "conflictStrategy": "skip" | "overwrite" | "review", "data": "<raw JSON array or CSV string>" }Alternatively,
multipart/form-datawith a file upload — both should be supported.conflictStrategyControls what happens when an imported record matches an existing record (same
quote+sourcetext) but has different field values.skipoverwritereviewA record with no field differences is always silently skipped — it is identical to what already exists.
enrichOptional (default
false). Whentrue, runs the enrichment service (#19) on each successfully imported row after writing.Response
{ "imported": 10, "skipped": 2, "enriched": 7, "errors": [ { "row": 4, "field": "type", "message": "Unknown type 'videogame'" } ], "conflicts": [ { "importId": "client-assigned-id-or-row-index", "existingId": "uuid-of-existing-record", "fields": [ { "field": "date", "existingValue": "1994", "importedValue": "1995" }, { "field": "genres", "existingValue": ["drama"], "importedValue": ["drama", "thriller"] } ] } ] }conflictsis populated only whenconflictStrategy: "review". Each conflict includes enough information for the UI to render a side-by-side comparison.Conflict resolution endpoint
POST /api/v1/quotes/import/resolveAccepts per-field resolutions for conflicts returned by a prior
reviewimport. The user decides each field independently.{ "resolutions": [ { "existingId": "uuid", "fields": [ { "field": "date", "resolution": "keep" | "replace" }, { "field": "genres", "resolution": "keep" | "replace" } ] } ] }Each resolution is recorded in the audit log (#56) with
ActorType='user',Action='updated', and old/new values — giving a clear record of who decided what and why.General behaviour
createdAtandupdatedAtset to time of import — source timestamps ignoredoriginalLanguagedefaults to"en"if absentenrich: true, enrichment runs after commit; a failed lookup leaves the row as-is without rolling back the importFormats supported
quotes.jsonschemaquote,source,author,character,type,genres,date,originalLanguageExisting sources as the format reference
The two seed datasets (vilaboim/movie-quotes and NikhilNamal17/popular-movie-quotes) conform to the JSON schema after seed script normalisation. The import endpoint must accept that output directly.
Enrichment integration
Shared with the enrichment script (#19) via
Quotinator.Enrichment. The import endpoint calls the same service whenenrich: true. Design decision on library vs. project resolved in #19.Auth
Requires API key authentication (#15) — same policy as write endpoints.