diff --git a/api/openapi.yaml b/api/openapi.yaml index 8608dd89d..03c1f684b 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1397,6 +1397,70 @@ components: required: - stats type: object + CommunicationService: + additionalProperties: true + properties: + aliases: + items: + type: string + type: + - array + - "null" + created_at: + format: date-time + type: string + default_scope_kind: + type: string + display_label: + type: string + id: + format: int64 + type: integer + is_active: + type: boolean + is_system: + type: boolean + normalization: + type: string + normalization_version: + format: int64 + type: integer + profile_url_template: + type: string + scope_policy: + type: string + slug: + type: string + updated_at: + format: date-time + type: string + uri_scheme: + type: string + required: + - id + - slug + - display_label + - aliases + - scope_policy + - normalization + - normalization_version + - is_system + - is_active + - created_at + - updated_at + type: object + CommunicationServicesResponse: + additionalProperties: true + properties: + services: + items: + $ref: "#/components/schemas/CommunicationService" + type: + - array + - "null" + required: + - services + type: object ConversationResponse: additionalProperties: true properties: @@ -1472,6 +1536,49 @@ components: - value_type - field_type type: object + CreateCommunicationServiceRequest: + additionalProperties: false + properties: + aliases: + items: + type: string + type: + - array + - "null" + default_scope_kind: + type: string + display_label: + type: string + normalization: + enum: + - none + - lower + - email + - phone_e164 + - strip_at_lower + - by_address_kind + type: string + normalization_version: + format: int64 + type: integer + profile_url_template: + type: string + scope_policy: + enum: + - none + - optional + - required + type: string + slug: + type: string + uri_scheme: + type: string + required: + - slug + - display_label + - scope_policy + - normalization + type: object CreatePersonRequest: additionalProperties: false properties: @@ -3487,6 +3594,61 @@ components: required: - busy type: object + PartialDate: + additionalProperties: false + properties: + day: + format: int64 + type: integer + month: + format: int64 + type: integer + year: + format: int64 + type: integer + type: object + ParticipantContactObservation: + additionalProperties: true + properties: + address_kind: + type: string + envelope: + $ref: "#/components/schemas/ValueEnvelope" + normalization: + type: string + normalization_version: + format: int64 + type: integer + normalized_value: + type: string + observed_at: + format: date-time + type: string + original_value: + type: string + participant_id: + format: int64 + type: integer + provider_user_id: + type: string + scope_kind: + type: string + scope_value: + type: string + service_slug: + type: string + source_id: + format: int64 + type: integer + required: + - envelope + - participant_id + - address_kind + - original_value + - normalized_value + - normalization + - normalization_version + type: object PatchAttributeDefinitionRequest: additionalProperties: false properties: @@ -3559,6 +3721,110 @@ components: - created_at - updated_at type: object + PersonAddress: + additionalProperties: true + properties: + address_kind: + type: string + country_code: + type: string + country_name: + type: string + envelope: + $ref: "#/components/schemas/ValueEnvelope" + extended_address: + type: string + extended_components: + type: string + free_text: + type: string + geo_uri: + type: string + label: + type: string + locality: + type: string + original_value: + type: string + person_id: + format: int64 + type: integer + place_uri: + type: string + post_office_box: + type: string + postal_code: + type: string + region: + type: string + street_address: + type: string + timezone: + type: string + required: + - envelope + - person_id + - address_kind + - original_value + type: object + PersonAddressInputRequest: + additionalProperties: false + properties: + address_kind: + type: string + country_code: + type: string + country_name: + type: string + envelope: + $ref: "#/components/schemas/ValueEnvelopeInput" + extended_address: + type: string + extended_components: + type: string + free_text: + type: string + geo_uri: + type: string + label: + type: string + locality: + type: string + original_value: + type: string + place_uri: + type: string + post_office_box: + type: string + postal_code: + type: string + region: + type: string + street_address: + type: string + timezone: + type: string + required: + - address_kind + - envelope + type: object + PersonAddressPatchRequest: + additionalProperties: false + properties: + add: + items: + $ref: "#/components/schemas/PersonAddressInputRequest" + type: + - array + - "null" + supersede: + items: + format: int64 + type: integer + type: + - array + - "null" + type: object PersonAttributeGroup: additionalProperties: true properties: @@ -3659,6 +3925,52 @@ components: - person_id - attributes type: object + PersonCategory: + additionalProperties: true + properties: + envelope: + $ref: "#/components/schemas/ValueEnvelope" + normalized_value: + type: string + original_value: + type: string + person_id: + format: int64 + type: integer + required: + - envelope + - person_id + - original_value + - normalized_value + type: object + PersonCategoryInputRequest: + additionalProperties: false + properties: + envelope: + $ref: "#/components/schemas/ValueEnvelopeInput" + original_value: + type: string + required: + - original_value + - envelope + type: object + PersonCategoryPatchRequest: + additionalProperties: false + properties: + add: + items: + $ref: "#/components/schemas/PersonCategoryInputRequest" + type: + - array + - "null" + supersede: + items: + format: int64 + type: integer + type: + - array + - "null" + type: object PersonCluster: additionalProperties: true properties: @@ -3696,6 +4008,81 @@ components: - participant_a - participant_b type: object + PersonContactPoint: + additionalProperties: true + properties: + address_kind: + type: string + envelope: + $ref: "#/components/schemas/ValueEnvelope" + normalization: + type: string + normalization_version: + format: int64 + type: integer + normalized_value: + type: string + original_value: + type: string + person_id: + format: int64 + type: integer + scope_kind: + type: string + scope_value: + type: string + service_slug: + type: string + uri: + type: string + required: + - envelope + - person_id + - address_kind + - original_value + - normalized_value + - normalization + - normalization_version + type: object + PersonContactPointInputRequest: + additionalProperties: false + properties: + address_kind: + type: string + envelope: + $ref: "#/components/schemas/ValueEnvelopeInput" + original_value: + type: string + scope_kind: + type: string + scope_value: + type: string + service_slug: + type: string + uri: + type: string + required: + - address_kind + - original_value + - envelope + type: object + PersonContactPointPatchRequest: + additionalProperties: false + properties: + add: + items: + $ref: "#/components/schemas/PersonContactPointInputRequest" + type: + - array + - "null" + supersede: + items: + format: int64 + type: integer + type: + - array + - "null" + type: object PersonContextSummaryHTTPResponse: additionalProperties: true properties: @@ -3708,32 +4095,269 @@ components: summary: $ref: "#/components/schemas/PersonSummary" required: - - summary - - cache_revision - - search_provenance + - summary + - cache_revision + - search_provenance + type: object + PersonDate: + additionalProperties: true + properties: + calendar_scale: + type: string + date: + $ref: "#/components/schemas/PartialDate" + date_kind: + type: string + date_text: + type: string + envelope: + $ref: "#/components/schemas/ValueEnvelope" + label: + type: string + original_value: + type: string + person_id: + format: int64 + type: integer + required: + - envelope + - person_id + - date_kind + - date + - original_value + type: object + PersonDateInputRequest: + additionalProperties: false + properties: + calendar_scale: + type: string + date: + $ref: "#/components/schemas/PartialDate" + date_kind: + type: string + date_text: + type: string + envelope: + $ref: "#/components/schemas/ValueEnvelopeInput" + label: + type: string + original_value: + type: string + required: + - date_kind + - envelope + type: object + PersonDatePatchRequest: + additionalProperties: false + properties: + add: + items: + $ref: "#/components/schemas/PersonDateInputRequest" + type: + - array + - "null" + supersede: + items: + format: int64 + type: integer + type: + - array + - "null" + type: object + PersonIdentifier: + additionalProperties: true + properties: + display_value: + type: string + is_primary: + type: boolean + participant_id: + format: int64 + type: integer + provenance: + type: string + type: + type: string + value: + type: string + required: + - type + - value + - is_primary + - provenance + - participant_id + type: object + PersonMedia: + additionalProperties: true + properties: + byte_size: + format: int64 + type: integer + content_hash: + type: string + envelope: + $ref: "#/components/schemas/ValueEnvelope" + has_data: + type: boolean + media_kind: + type: string + media_type: + type: string + original_value: + type: string + person_id: + format: int64 + type: integer + uri: + type: string + required: + - envelope + - person_id + - media_kind + - has_data + - original_value + type: object + PersonMediaInputRequest: + additionalProperties: false + properties: + data: + contentEncoding: base64 + type: string + envelope: + $ref: "#/components/schemas/ValueEnvelopeInput" + media_kind: + type: string + media_type: + type: string + original_value: + type: string + uri: + type: string + required: + - media_kind + - envelope + type: object + PersonMediaPatchRequest: + additionalProperties: false + properties: + add: + items: + $ref: "#/components/schemas/PersonMediaInputRequest" + type: + - array + - "null" + supersede: + items: + format: int64 + type: integer + type: + - array + - "null" + type: object + PersonName: + additionalProperties: true + properties: + additional_names: + type: string + envelope: + $ref: "#/components/schemas/ValueEnvelope" + family_name: + type: string + formatted: + type: string + generation: + type: string + given_name: + type: string + honorific_prefixes: + type: string + honorific_suffixes: + type: string + is_derived: + type: boolean + language: + type: string + name_kind: + type: string + original_value: + type: string + person_id: + format: int64 + type: integer + phonetic_script: + type: string + phonetic_system: + type: string + script: + type: string + secondary_surname: + type: string + sort_as: + type: string + required: + - envelope + - person_id + - name_kind + - is_derived + - original_value type: object - PersonIdentifier: - additionalProperties: true + PersonNameInputRequest: + additionalProperties: false properties: - display_value: + additional_names: type: string - is_primary: + envelope: + $ref: "#/components/schemas/ValueEnvelopeInput" + family_name: + type: string + formatted: + type: string + generation: + type: string + given_name: + type: string + honorific_prefixes: + type: string + honorific_suffixes: + type: string + is_derived: type: boolean - participant_id: - format: int64 - type: integer - provenance: + language: type: string - type: + name_kind: type: string - value: + original_value: + type: string + phonetic_script: + type: string + phonetic_system: + type: string + script: + type: string + secondary_surname: + type: string + sort_as: type: string required: - - type - - value - - is_primary - - provenance - - participant_id + - name_kind + - envelope + type: object + PersonNamePatchRequest: + additionalProperties: false + properties: + add: + items: + $ref: "#/components/schemas/PersonNameInputRequest" + type: + - array + - "null" + supersede: + items: + format: int64 + type: integer + type: + - array + - "null" type: object PersonProfile: additionalProperties: true @@ -3750,6 +4374,79 @@ components: - id - revision type: object + PersonProfileHistory: + additionalProperties: true + properties: + addresses: + items: + $ref: "#/components/schemas/PersonAddress" + type: + - array + - "null" + categories: + items: + $ref: "#/components/schemas/PersonCategory" + type: + - array + - "null" + contact_points: + items: + $ref: "#/components/schemas/PersonContactPoint" + type: + - array + - "null" + dates: + items: + $ref: "#/components/schemas/PersonDate" + type: + - array + - "null" + media: + items: + $ref: "#/components/schemas/PersonMedia" + type: + - array + - "null" + names: + items: + $ref: "#/components/schemas/PersonName" + type: + - array + - "null" + observations: + items: + $ref: "#/components/schemas/ParticipantContactObservation" + type: + - array + - "null" + person: + $ref: "#/components/schemas/Person" + required: + - person + - names + - contact_points + - addresses + - dates + - categories + - media + - observations + type: object + PersonProfilePatchRequest: + additionalProperties: false + properties: + addresses: + $ref: "#/components/schemas/PersonAddressPatchRequest" + categories: + $ref: "#/components/schemas/PersonCategoryPatchRequest" + contact_points: + $ref: "#/components/schemas/PersonContactPointPatchRequest" + dates: + $ref: "#/components/schemas/PersonDatePatchRequest" + media: + $ref: "#/components/schemas/PersonMediaPatchRequest" + names: + $ref: "#/components/schemas/PersonNamePatchRequest" + type: object PersonSearchHTTPResponse: additionalProperties: true properties: @@ -4901,6 +5598,56 @@ components: - status - message type: object + StructuredPersonProfile: + additionalProperties: true + properties: + addresses: + items: + $ref: "#/components/schemas/PersonAddress" + type: + - array + - "null" + categories: + items: + $ref: "#/components/schemas/PersonCategory" + type: + - array + - "null" + contact_points: + items: + $ref: "#/components/schemas/PersonContactPoint" + type: + - array + - "null" + dates: + items: + $ref: "#/components/schemas/PersonDate" + type: + - array + - "null" + media: + items: + $ref: "#/components/schemas/PersonMedia" + type: + - array + - "null" + names: + items: + $ref: "#/components/schemas/PersonName" + type: + - array + - "null" + person: + $ref: "#/components/schemas/Person" + required: + - person + - names + - contact_points + - addresses + - dates + - categories + - media + type: object Summary: additionalProperties: false properties: @@ -5413,6 +6160,112 @@ components: - email - display_name type: object + VCardIdentity: + additionalProperties: false + properties: + altid: + type: string + group: + type: string + pid: + items: + type: string + type: + - array + - "null" + prop_id: + type: string + property: + type: string + type: object + ValueEnvelope: + additionalProperties: true + properties: + active_from: + format: date-time + type: string + active_until: + format: date-time + type: string + confidence: + format: double + type: number + created_at: + format: date-time + type: string + id: + format: int64 + type: integer + ordinal: + format: int64 + type: integer + pref: + format: int64 + type: integer + source: + type: string + source_ref: + type: string + superseded_at: + format: date-time + type: string + type_label: + type: string + type_tokens: + items: + type: string + type: + - array + - "null" + updated_at: + format: date-time + type: string + vcard: + $ref: "#/components/schemas/VCardIdentity" + required: + - id + - ordinal + - vcard + - source + - created_at + - updated_at + type: object + ValueEnvelopeInput: + additionalProperties: false + properties: + active_from: + format: date-time + type: string + active_until: + format: date-time + type: string + confidence: + format: double + type: number + ordinal: + format: int64 + minimum: 0 + type: integer + pref: + format: int64 + type: integer + source: + type: string + source_ref: + type: string + type_label: + type: string + type_tokens: + items: + type: string + type: + - array + - "null" + vcard: + $ref: "#/components/schemas/VCardIdentity" + required: + - source + type: object VectorHealth: additionalProperties: true properties: @@ -5430,7 +6283,7 @@ components: type: apiKey info: title: msgvault API - version: 1.36.0 + version: 1.38.0 openapi: 3.1.0 paths: /api/ping: @@ -7555,8 +8408,100 @@ paths: content: application/x-ndjson: schema: - $ref: "#/components/schemas/CLIVerifyEvent" + $ref: "#/components/schemas/CLIVerifyEvent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Verify the CLI archive against Gmail + tags: + - API + /api/v1/communication-services: + get: + description: Lists the small open service catalog without pagination, including aliases and normalization policy. + operationId: listCommunicationServices + parameters: + - description: Include inactive catalog entries + in: query + name: include_inactive + schema: + default: false + type: boolean + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/CommunicationServicesResponse" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: List communication services + tags: + - API + post: + description: Registers an unknown or custom service without a schema migration. Re-registering a slug is idempotent. + operationId: createCommunicationService + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreateCommunicationServiceRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/CommunicationService" description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error default: content: application/json: @@ -7565,7 +8510,7 @@ paths: description: Error security: - apiKey: [] - summary: Verify the CLI archive against Gmail + summary: Register a communication service tags: - API /api/v1/content/remote-image: @@ -9896,6 +10841,258 @@ paths: summary: Set a person's attribute value tags: - API + /api/v1/persons/{id}/profile: + get: + description: Returns only current structured values at one person revision. Superseded values and archive observations are available from the separate history endpoint. + operationId: getPersonStructuredProfile + parameters: + - description: Durable person ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/StructuredPersonProfile" + description: OK + headers: + ETag: + description: Strong person profile revision tag for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Get a person's current structured profile + tags: + - API + patch: + description: Applies up to 200 explicit adds and supersedes atomically under If-Match. One patch advances the person revision once. Superseding closes world and transaction time without deletion. + operationId: patchPersonStructuredProfile + parameters: + - description: Durable person ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Strong ETag returned by the latest person profile read. Must be the exact single tag from that read; the RFC 7232 forms `*` and comma-separated tag lists are not supported. + in: header + name: If-Match + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PersonProfilePatchRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/StructuredPersonProfile" + description: OK + headers: + ETag: + description: Strong person profile revision tag for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "413": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Atomically patch a person's structured profile + tags: + - API + /api/v1/persons/{id}/profile/history: + get: + description: Returns current and superseded structured values plus source-linked observations for every participant bound to the person. + operationId: getPersonProfileHistory + parameters: + - description: Durable person ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonProfileHistory" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Get a person's structured profile history + tags: + - API + /api/v1/persons/{id}/profile/media/{media_id}/content: + get: + description: Returns the exact inline bytes stored for one media value. URI-only values have no local content and return 404. + operationId: getPersonProfileMediaContent + parameters: + - description: Durable person ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Structured person profile media value ID + in: path + name: media_id + required: true + schema: + format: int64 + type: integer + responses: + "200": + content: + "*/*": + schema: + contentMediaType: application/octet-stream + format: binary + type: string + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Download stored inline content for one person profile media value + tags: + - API /api/v1/query: post: operationId: runQuery diff --git a/cmd/msgvault/cmd/build_cache.go b/cmd/msgvault/cmd/build_cache.go index 250650b91..d188f77ca 100644 --- a/cmd/msgvault/cmd/build_cache.go +++ b/cmd/msgvault/cmd/build_cache.go @@ -481,7 +481,7 @@ type buildResult struct { MaxMessageID int64 OutputDir string Skipped bool - IdentityOnly bool // true when only owner_participants/participant_clusters were refreshed + IdentityOnly bool // true when only derived cache datasets were refreshed } // buildCache honors an explicit full rebuild unconditionally. Default builds @@ -583,12 +583,24 @@ func participantIdentifiersExportSelectSQL() string { FROM sqlite_db.participant_identifiers` } +// participantsExportSelectSQL renders the participants dataset export. The +// full and derived-only builders share this query so participant rows and +// display names cannot drift between cache publication paths. +func participantsExportSelectSQL() string { + return `SELECT + id, + COALESCE(TRY_CAST(email_address AS VARCHAR), '') AS email_address, + COALESCE(TRY_CAST(domain AS VARCHAR), '') AS domain, + COALESCE(TRY_CAST(display_name AS VARCHAR), '') AS display_name, + COALESCE(TRY_CAST(phone_number AS VARCHAR), '') AS phone_number + FROM sqlite_db.participants` +} + // derivedDriftOnly reports whether participant-link, conversation-membership, -// conversation-type, or participant-identifier drift is the only staleness -// signal. The index-only refresh rebuilds the four relationship datasets from -// committed base Parquet without re-exporting it (re-staging only the drifted -// replaceable base dataset: conversation_participants, conversations, or -// participant_identifiers). +// conversation-type, participant-identifier, or participant display-name drift +// is the only staleness signal. The index-only refresh rebuilds the four +// relationship datasets from committed base Parquet while re-staging any +// drifted replaceable base dataset. // // HasAccountIdentityDrift is excluded even though it also bumps // identity_revision (and therefore HasIdentityDrift): confirming or @@ -598,7 +610,8 @@ func participantIdentifiersExportSelectSQL() string { // path. func derivedDriftOnly(staleness cacheStaleness) bool { return (staleness.HasIdentityDrift || staleness.HasConversationParticipantDrift || - staleness.HasConversationTypeDrift || staleness.HasParticipantIdentifierDrift) && + staleness.HasConversationTypeDrift || staleness.HasParticipantIdentifierDrift || + staleness.HasParticipantDisplayNameDrift) && !staleness.HasNew && !staleness.HasDeleted && !staleness.HasUpdated && !staleness.HasAccountIdentityDrift } @@ -678,10 +691,11 @@ func buildCacheLocked( // concurrent identity mutation therefore makes the stamped revision LAG // the store, which HasIdentityDrift detects on the next staleness check — // the cache self-heals. Never move this read after the export. The same - // invariant applies to the account-identity revision read alongside it: - // this full build derives is_from_me fresh from the current store state, - // so stamping a lagging account-identity revision here is likewise - // self-healing — HasAccountIdentityDrift catches it on the next check. + // invariant applies to the account-identity, participant-identifier, and + // participant display-name revisions read alongside it: this full build + // derives all of those datasets fresh from the current store state, so + // stamping a lagging revision here is likewise self-healing — the matching + // staleness check catches it on the next pass. identityStore, err := store.Open(dbPath) if err != nil { return nil, fmt.Errorf("open store for identity export: %w", err) @@ -701,6 +715,11 @@ func buildCacheLocked( _ = identityStore.Close() return nil, fmt.Errorf("read participant identifier revision: %w", err) } + participantDisplayNameRevision, err := identityStore.ParticipantDisplayNameRevision() + if err != nil { + _ = identityStore.Close() + return nil, fmt.Errorf("read participant display-name revision: %w", err) + } participantClusters, err := identityStore.ParticipantClusters() if err != nil { _ = identityStore.Close() @@ -984,18 +1003,12 @@ func buildCacheLocked( escapedParticipantsDir := strings.ReplaceAll(participantsDir, "'", "''") if err := runExport(tableParticipants, fmt.Sprintf(` COPY ( - SELECT - id, - COALESCE(TRY_CAST(email_address AS VARCHAR), '') as email_address, - COALESCE(TRY_CAST(domain AS VARCHAR), '') as domain, - COALESCE(TRY_CAST(display_name AS VARCHAR), '') as display_name, - COALESCE(TRY_CAST(phone_number AS VARCHAR), '') as phone_number - FROM sqlite_db.participants + %s ) TO '%s/participants.parquet' ( FORMAT PARQUET, COMPRESSION 'zstd' ) - `, escapedParticipantsDir)); err != nil { + `, participantsExportSelectSQL(), escapedParticipantsDir)); err != nil { return nil, fmt.Errorf("export participants: %w", err) } @@ -1364,6 +1377,7 @@ func buildCacheLocked( IdentityRevision: identityRevision, AccountIdentityRevision: accountIdentityRevision, ParticipantIdentifierRevision: participantIdentifierRevision, + ParticipantDisplayNameRevision: participantDisplayNameRevision, ConversationParticipantsFingerprint: derived.ConversationParticipantsFingerprint, ConversationTypesFingerprint: typesFingerprint, Stats: derived.Stats, diff --git a/cmd/msgvault/cmd/build_cache_test.go b/cmd/msgvault/cmd/build_cache_test.go index 90d1149f3..a79aabd94 100644 --- a/cmd/msgvault/cmd/build_cache_test.go +++ b/cmd/msgvault/cmd/build_cache_test.go @@ -3302,7 +3302,8 @@ func TestCacheNeedsBuild_IgnoresAlreadyProcessedUpdatedSyncRun(t *testing.T) { // schema version other than the current one now forces a full rebuild. func TestCacheNeedsBuild_SchemaVersionMismatch(t *testing.T) { require := require.New(t) - require.Equal(17, cacheSchemaVersion, "message_recipients envelope address requires cache v17") + require.Equal(18, cacheSchemaVersion, + "participant directory revisions require a one-time cache rebuild at v18") tmpDir := setupTestSQLiteEmpty(t) dbPath := filepath.Join(tmpDir, "test.db") @@ -3337,7 +3338,7 @@ func TestCacheNeedsBuild_SchemaVersionMismatch(t *testing.T) { require.False(result.Skipped, "schema mismatch must execute a full rebuild") upgraded, err := query.ReadCacheSyncState(analyticsDir) require.NoError(err, "read upgraded cache state") - require.Equal(17, upgraded.SchemaVersion) + require.Equal(18, upgraded.SchemaVersion) require.NoFileExists(filepath.Join(analyticsDir, tableParticipantIdentifiers, "data.parquet"), "full rebuild must replace rather than extend the v11 identifier dataset") identifierParquet := filepath.Join(analyticsDir, tableParticipantIdentifiers, "participant_identifiers.parquet") diff --git a/cmd/msgvault/cmd/cache_derived.go b/cmd/msgvault/cmd/cache_derived.go index 3c2165057..5538f0dc9 100644 --- a/cmd/msgvault/cmd/cache_derived.go +++ b/cmd/msgvault/cmd/cache_derived.go @@ -84,6 +84,11 @@ func refreshDerivedDatasetsOnly( _ = st.Close() return nil, fmt.Errorf("read participant identifier revision: %w", err) } + participantDisplayNameRevision, err := st.ParticipantDisplayNameRevision() + if err != nil { + _ = st.Close() + return nil, fmt.Errorf("read participant display-name revision: %w", err) + } clusters, err := st.ParticipantClusters() if err != nil { _ = st.Close() @@ -139,6 +144,7 @@ func refreshDerivedDatasetsOnly( if identityRevision == state.IdentityRevision && participantIdentifierRevision == state.ParticipantIdentifierRevision && + participantDisplayNameRevision == state.ParticipantDisplayNameRevision && conversationFingerprint == state.ConversationParticipantsFingerprint && typesFingerprint == state.ConversationTypesFingerprint { // Nothing the derived datasets read has changed (the account-identity @@ -177,6 +183,16 @@ func refreshDerivedDatasetsOnly( return nil, err } } + displayNamesChanged := + participantDisplayNameRevision != state.ParticipantDisplayNameRevision + if identifiersChanged || displayNamesChanged { + // Participant identifiers can create participant rows, and display-name + // mutations change the row already present in participants.parquet. Both + // changes must replace that base dataset before rebuilding the directory. + if err := exportDerivedParticipants(ctx, exportDB, staging.root); err != nil { + return nil, err + } + } typesChanged := typesFingerprint != state.ConversationTypesFingerprint if typesChanged { // The index rebuild reads conversation_type from the conversations @@ -213,11 +229,17 @@ func refreshDerivedDatasetsOnly( state.IdentityRevision = identityRevision state.ParticipantIdentifierRevision = participantIdentifierRevision + state.ParticipantDisplayNameRevision = participantDisplayNameRevision state.ConversationParticipantsFingerprint = conversationFingerprint state.ConversationTypesFingerprint = typesFingerprint // Stats describe the unchanged committed raw snapshot. Preserve them // byte-for-byte instead of scanning Parquet again. - plan := derivedCachePublishPlan(conversationChanged, typesChanged, identifiersChanged) + plan := derivedCachePublishPlan( + conversationChanged, + typesChanged, + identifiersChanged, + identifiersChanged || displayNamesChanged, + ) if err := publishDerivedCache(staging, analyticsDir, plan, state, locking); err != nil { return nil, err } @@ -254,10 +276,10 @@ func fingerprintConversationParticipantsFromSnapshot( // fingerprintConversationTypesFromSnapshot mirrors // sourceConversationTypesFingerprint over the export snapshot, so the stamp -// written at publish time describes exactly the types the staged datasets -// baked. The 'email_thread' normalization matches the staleness query (and -// the CSV snapshot view, which pre-applies it), NOT the exported parquet -// value — fingerprints only ever compare against each other. +// written at publish time describes exactly the type/title metadata the staged +// datasets baked. The normalizations match the staleness query (and the CSV +// snapshot view), not the exported Parquet values; fingerprints only compare +// against each other. func fingerprintConversationTypesFromSnapshot( ctx context.Context, db sqlRunner, @@ -265,7 +287,8 @@ func fingerprintConversationTypesFromSnapshot( ) (string, error) { rows, err := db.QueryContext(ctx, fmt.Sprintf(` SELECT c.id::BIGINT, - COALESCE(TRY_CAST(c.conversation_type AS VARCHAR), 'email_thread') + COALESCE(TRY_CAST(c.conversation_type AS VARCHAR), 'email_thread'), + COALESCE(TRY_CAST(c.title AS VARCHAR), '') FROM sqlite_db.conversations c WHERE EXISTS ( SELECT 1 @@ -277,12 +300,12 @@ func fingerprintConversationTypesFromSnapshot( ORDER BY c.id `, exportableMessageWhere("m")), lastMessageID) if err != nil { - return "", fmt.Errorf("query conversation types from source snapshot: %w", err) + return "", fmt.Errorf("query conversation metadata from source snapshot: %w", err) } defer func() { _ = rows.Close() }() - fingerprint, err := identityindex.FingerprintConversationTypes(rows) + fingerprint, err := identityindex.FingerprintConversationMetadata(rows) if rowsErr := rows.Err(); rowsErr != nil && err == nil { - return "", fmt.Errorf("iterate source conversation types: %w", rowsErr) + return "", fmt.Errorf("iterate source conversation metadata: %w", rowsErr) } return fingerprint, err } @@ -346,6 +369,30 @@ func exportDerivedOwnerParticipants( return nil } +// exportDerivedParticipants re-stages the participants base dataset when an +// identifier creates a participant or a display-name mutation changes an +// existing row. The relationship directory reads this dataset directly. +func exportDerivedParticipants( + ctx context.Context, + db sqlRunner, + stagingRoot string, +) error { + dir := filepath.Join(stagingRoot, tableParticipants) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create derived participants directory: %w", err) + } + path := filepath.Join(dir, "participants.parquet") + _, err := db.ExecContext(ctx, fmt.Sprintf(` + COPY ( + %s + ) TO '%s' (FORMAT PARQUET, COMPRESSION 'zstd') + `, participantsExportSelectSQL(), quoteCacheSQL(path))) + if err != nil { + return fmt.Errorf("export derived participants: %w", err) + } + return nil +} + // exportDerivedParticipantIdentifiers re-stages the participant_identifiers // base dataset with the full export query so an index-only refresh triggered // by identifier drift rebuilds the identity directory from current mappings @@ -447,7 +494,8 @@ func quoteCacheSQL(value string) string { } func derivedCachePublishPlan( - includeConversationParticipants, includeConversations, includeParticipantIdentifiers bool, + includeConversationParticipants, includeConversations, + includeParticipantIdentifiers, includeParticipants bool, ) cachePublishPlan { plan := cachePublishPlan{ Append: make(map[string]bool), @@ -472,6 +520,9 @@ func derivedCachePublishPlan( if includeParticipantIdentifiers { plan.Replace[tableParticipantIdentifiers] = true } + if includeParticipants { + plan.Replace[tableParticipants] = true + } return plan } diff --git a/cmd/msgvault/cmd/cache_refresh_test.go b/cmd/msgvault/cmd/cache_refresh_test.go index e9bdd2da1..401f90b90 100644 --- a/cmd/msgvault/cmd/cache_refresh_test.go +++ b/cmd/msgvault/cmd/cache_refresh_test.go @@ -366,6 +366,9 @@ func TestRepairEncodingReturnsCacheRefreshError(t *testing.T) { savedCfg := cfg t.Cleanup(func() { cfg = savedCfg }) cfg = &config.Config{HomeDir: tmpDir, Data: config.DataConfig{DataDir: tmpDir}} + stateFile := filepath.Join(cfg.AnalyticsDir(), "_last_sync.json") + require.NoError(os.MkdirAll(cfg.AnalyticsDir(), 0o755)) + require.NoError(os.WriteFile(stateFile, []byte(`{"schema_version":18}`), 0o600)) sentinel := errors.New("repair cache sentinel") buildCacheBeforeMessagesExportHook = func() error { return sentinel } @@ -375,6 +378,8 @@ func TestRepairEncodingReturnsCacheRefreshError(t *testing.T) { require.ErrorIs(err, sentinel) require.ErrorContains(err, "encoding repair completed") require.ErrorContains(err, "analytics cache refresh failed") + require.NoFileExists(stateFile, + "a failed post-repair rebuild must leave the old cache marked stale") } func TestScheduledCacheRefreshFailurePreservesCompletedSyncRun(t *testing.T) { @@ -460,7 +465,7 @@ func TestConversationTypeDriftDetectedAndRepairedByDerivedRefresh(t *testing.T) assertions.False(staleness.FullRebuild, "type drift without new messages must stay repairable by the derived refresh") assertions.True(derivedDriftOnly(staleness)) - assertions.Contains(staleness.Reason, "conversation types changed") + assertions.Contains(staleness.Reason, "conversation metadata changed") result, err := buildCacheDerivedOnly(dbPath, analyticsDir) requirements.NoError(err) @@ -502,6 +507,49 @@ func TestConversationTypeDriftDetectedAndRepairedByDerivedRefresh(t *testing.T) "repaired cache must be clean (reason: %q)", repaired.Reason) } +func TestConversationTitleDriftDetectedAndRepairedByDerivedRefresh(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + tmp := setupTestSQLite(t) + dbPath := filepath.Join(tmp, "test.db") + analyticsDir := filepath.Join(tmp, "analytics") + _, err := buildCache(dbPath, analyticsDir, true) + requirements.NoError(err) + messagesBefore := snapshotMessagesDatasetBytes(t, analyticsDir) + + st, err := store.Open(dbPath) + requirements.NoError(err) + _, err = st.DB().Exec( + `UPDATE conversations SET title = 'Updated cache title' WHERE id = 102`) + requirements.NoError(err) + requirements.NoError(st.Close()) + + staleness := cacheNeedsBuild(dbPath, analyticsDir) + requirements.True(staleness.NeedsBuild) + assertions.True(staleness.HasConversationTypeDrift) + assertions.False(staleness.FullRebuild, + "title drift without new messages must stay repairable by the derived refresh") + assertions.Contains(staleness.Reason, "conversation metadata changed") + + result, err := buildCacheDerivedOnly(dbPath, analyticsDir) + requirements.NoError(err) + assertions.True(result.IdentityOnly) + assertions.Equal(messagesBefore, snapshotMessagesDatasetBytes(t, analyticsDir), + "derived refresh must not rewrite message facts") + + duckDB, err := duckdbutil.Open( + context.Background(), + duckdbutil.BuilderPolicy(filepath.Join(tmp, "title-duckdb-tmp")), + ) + requirements.NoError(err) + defer func() { require.NoError(t, duckDB.Close()) }() + var title string + requirements.NoError(duckDB.QueryRow(` + SELECT title FROM read_parquet(?) WHERE id = 102 + `, filepath.Join(analyticsDir, tableConversations, "*.parquet")).Scan(&title)) + assertions.Equal("Updated cache title", title) +} + func TestFullBuildForcedWhenTypeDriftCoincidesWithNewMessages(t *testing.T) { requirements := require.New(t) assertions := assert.New(t) @@ -530,6 +578,94 @@ func TestFullBuildForcedWhenTypeDriftCoincidesWithNewMessages(t *testing.T) { assertions.False(derivedDriftOnly(staleness)) } +// TestParticipantDisplayNameDriftDetectedAndRepairedByDerivedRefresh pins +// display-name changes to the derived-only cache path: participants.parquet +// and relationship_people must be republished, while message facts remain +// byte-identical and no full rebuild is required. +func TestParticipantDisplayNameDriftDetectedAndRepairedByDerivedRefresh(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + tmp := setupTestSQLite(t) + dbPath := filepath.Join(tmp, "test.db") + analyticsDir := filepath.Join(tmp, "analytics") + _, err := buildCache(dbPath, analyticsDir, true) + requirements.NoError(err) + before, err := query.ReadCacheSyncState(analyticsDir) + requirements.NoError(err) + messagesBefore := snapshotMessagesDatasetBytes(t, analyticsDir) + + clean := cacheNeedsBuild(dbPath, analyticsDir) + requirements.False(clean.NeedsBuild, + "fresh build must not report staleness (reason: %q)", clean.Reason) + + st, err := store.Open(dbPath) + requirements.NoError(err) + // The identifier backfill API only fills an empty name. Clear the fixture + // value first so the mutation exercises the production write path that must + // advance ParticipantDisplayNameRevision without changing the identifier. + _, err = st.DB().Exec(`UPDATE participants SET display_name = NULL WHERE id = 1`) + requirements.NoError(err) + updatedID, err := st.EnsureParticipantByIdentifier( + "email", "alice@example.com", "Alice Updated", + ) + requirements.NoError(err) + requirements.Equal(int64(1), updatedID) + requirements.NoError(st.Close()) + + staleness := cacheNeedsBuild(dbPath, analyticsDir) + requirements.True(staleness.NeedsBuild) + assertions.True(staleness.HasParticipantDisplayNameDrift) + assertions.False(staleness.FullRebuild, + "display-name drift must stay repairable by the derived refresh") + assertions.True(derivedDriftOnly(staleness)) + assertions.Contains(staleness.Reason, "participant display names changed") + + result, err := buildCacheDerivedOnly(dbPath, analyticsDir) + requirements.NoError(err) + assertions.True(result.IdentityOnly) + assertions.False(result.Skipped) + + after, err := query.ReadCacheSyncState(analyticsDir) + requirements.NoError(err) + assertions.NotEqual( + before.ParticipantDisplayNameRevision, + after.ParticipantDisplayNameRevision, + ) + assertions.Equal(messagesBefore, snapshotMessagesDatasetBytes(t, analyticsDir), + "derived refresh must not rewrite message facts") + + duckDB, err := duckdbutil.Open( + context.Background(), + duckdbutil.BuilderPolicy(filepath.Join(tmp, "display-names-duckdb-tmp")), + ) + requirements.NoError(err) + defer func() { require.NoError(t, duckDB.Close()) }() + var participantName string + requirements.NoError(duckDB.QueryRow(` + SELECT display_name FROM read_parquet(?) WHERE id = 1 + `, filepath.Join(analyticsDir, tableParticipants, "*.parquet")).Scan(&participantName)) + assertions.Equal("Alice Updated", participantName, + "republished participants dataset must carry the new display name") + + var displayLabel string + requirements.NoError(duckDB.QueryRow(` + SELECT display_label FROM read_parquet(?) WHERE canonical_id = 1 + `, filepath.Join(analyticsDir, identityindex.DatasetPeople, "*.parquet")).Scan(&displayLabel)) + assertions.Equal("Alice Updated", displayLabel, + "relationship_people label must use the republished participant name") + var searchable bool + requirements.NoError(duckDB.QueryRow(` + SELECT list_contains(search_values, 'alice updated') + FROM read_parquet(?) WHERE canonical_id = 1 + `, filepath.Join(analyticsDir, identityindex.DatasetPeople, "*.parquet")).Scan(&searchable)) + assertions.True(searchable, + "relationship_people search values must include the new display name") + + repaired := cacheNeedsBuild(dbPath, analyticsDir) + assertions.False(repaired.NeedsBuild, + "repaired cache must be clean (reason: %q)", repaired.Reason) +} + // TestParticipantIdentifierDriftDetectedAndRepairedByDerivedRefresh pins the // staleness contract for identifier-mapping changes without message // activity: SetParticipantIdentifier alone must surface as derived-only @@ -605,6 +741,77 @@ func TestParticipantIdentifierDriftDetectedAndRepairedByDerivedRefresh(t *testin "repaired cache must be clean (reason: %q)", repaired.Reason) } +// TestParticipantIdentifierDriftCreatingParticipantRestagesParticipants pins +// the new-participant variant of identifier drift. Creating an identifier +// without message activity still adds a participants row that the derived +// refresh must publish; the message dataset must remain untouched. +func TestParticipantIdentifierDriftCreatingParticipantRestagesParticipants(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + tmp := setupTestSQLite(t) + dbPath := filepath.Join(tmp, "test.db") + analyticsDir := filepath.Join(tmp, "analytics") + _, err := buildCache(dbPath, analyticsDir, true) + requirements.NoError(err) + before, err := query.ReadCacheSyncState(analyticsDir) + requirements.NoError(err) + messagesBefore := snapshotMessagesDatasetBytes(t, analyticsDir) + + st, err := store.Open(dbPath) + requirements.NoError(err) + const newParticipantID int64 = 5 + // This cache fixture intentionally uses the legacy participant schema, so + // seed the new row directly and let the production identifier write create + // the drift watermark. The refresh must publish the row even though it has + // no message activity yet. + _, err = st.DB().Exec(` + INSERT INTO participants (id, email_address, domain, display_name) + VALUES (?, ?, ?, ?) + `, newParticipantID, "new-cache-participant@example.test", "example.test", "New Cache Participant") + requirements.NoError(err) + err = st.SetParticipantIdentifier( + newParticipantID, "slack", "new-cache-participant", + ) + requirements.NoError(err) + requirements.NoError(st.Close()) + + staleness := cacheNeedsBuild(dbPath, analyticsDir) + requirements.True(staleness.NeedsBuild) + assertions.True(staleness.HasParticipantIdentifierDrift) + assertions.False(staleness.FullRebuild, + "identifier drift that creates a participant must stay derived-only") + assertions.True(derivedDriftOnly(staleness)) + assertions.Contains(staleness.Reason, "participant identifiers changed") + + result, err := buildCacheDerivedOnly(dbPath, analyticsDir) + requirements.NoError(err) + assertions.True(result.IdentityOnly) + assertions.False(result.Skipped) + + after, err := query.ReadCacheSyncState(analyticsDir) + requirements.NoError(err) + assertions.NotEqual(before.ParticipantIdentifierRevision, after.ParticipantIdentifierRevision) + assertions.Equal(messagesBefore, snapshotMessagesDatasetBytes(t, analyticsDir), + "derived refresh must not rewrite message facts") + + duckDB, err := duckdbutil.Open( + context.Background(), + duckdbutil.BuilderPolicy(filepath.Join(tmp, "new-participant-duckdb-tmp")), + ) + requirements.NoError(err) + defer func() { require.NoError(t, duckDB.Close()) }() + var participantName string + requirements.NoError(duckDB.QueryRow(` + SELECT display_name FROM read_parquet(?) WHERE id = ? + `, filepath.Join(analyticsDir, tableParticipants, "*.parquet"), newParticipantID).Scan(&participantName)) + assertions.Equal("New Cache Participant", participantName, + "identifier drift that creates a participant must republish participants.parquet") + + repaired := cacheNeedsBuild(dbPath, analyticsDir) + assertions.False(repaired.NeedsBuild, + "repaired cache must be clean (reason: %q)", repaired.Reason) +} + // TestIncrementalBuildRepairsParticipantIdentifierDriftWithNewMessages pins // that identifier drift coinciding with new messages does NOT escalate to a // full rebuild the way link/membership/type drift does: identifiers are not diff --git a/cmd/msgvault/cmd/cache_staleness.go b/cmd/msgvault/cmd/cache_staleness.go index 9f9755f2f..6b20e7e09 100644 --- a/cmd/msgvault/cmd/cache_staleness.go +++ b/cmd/msgvault/cmd/cache_staleness.go @@ -28,23 +28,28 @@ type cacheStaleness struct { // watermark. The index-only refresh can rebuild relationship_activity and // its compact datasets without rewriting message facts. HasConversationParticipantDrift bool - // HasConversationTypeDrift signals conversation_type changed for a - // conversation already represented by the committed message watermark. - // The type is baked into committed relationship_activity rows (and the - // replaceable conversations base dataset), so like membership drift it - // is repaired by the index-only refresh — unless new messages also - // arrived, in which case the incremental append cannot rewrite the - // already-committed rows and a full rebuild is forced below. + // HasConversationTypeDrift signals conversation_type or title changed for + // a conversation already represented by the committed message watermark. + // This metadata is baked into committed relationship_activity rows (and + // the replaceable conversations base dataset), so like membership drift it + // is repaired by the index-only refresh — unless new messages also arrived, + // in which case the incremental append cannot rewrite the already-committed + // rows and a full rebuild is forced below. HasConversationTypeDrift bool - // HasParticipantIdentifierDrift signals identifier mappings changed - // (SetParticipantIdentifier created or repointed rows) since the last - // build. Identifiers bake into the identity directory datasets + // HasParticipantIdentifierDrift signals identifier rows or their + // service/scope classification changed since the last build. Identifiers + // bake into the identity directory datasets // (participant_identifiers, relationship_people search values) but not // into per-row activity facts, so this drift is repaired by the // index-only refresh and — unlike link or conversation drift — never // escalates to a full rebuild when new messages coincide: incremental // builds re-stage participant_identifiers in full anyway. HasParticipantIdentifierDrift bool + // HasParticipantDisplayNameDrift signals participant display-name changes + // since the last build. Display names are baked into participants.parquet + // and relationship_people, but not into message facts, so the index-only + // refresh can repair this drift without a full rebuild. + HasParticipantDisplayNameDrift bool // HasAccountIdentityDrift signals an identity mutation that invalidates // baked message data since the last build: an account identity was // confirmed or removed, or two participants were merged (merges repoint @@ -344,6 +349,18 @@ func cacheNeedsBuildLocked(dbPath, analyticsDir string) cacheStaleness { reasons = append(reasons, "participant identifiers changed") } + participantDisplayNameRevision, err := db.ParticipantDisplayNameRevision() + if err != nil { + return cacheStaleness{ + NeedsBuild: true, FullRebuild: true, + Reason: "cannot verify participant display-name revision", + } + } + if participantDisplayNameRevision != state.ParticipantDisplayNameRevision { + result.HasParticipantDisplayNameDrift = true + reasons = append(reasons, "participant display names changed") + } + conversationFingerprint, err := sourceConversationParticipantsFingerprint( db.DB(), state.LastMessageID, @@ -366,12 +383,12 @@ func cacheNeedsBuildLocked(dbPath, analyticsDir string) cacheStaleness { if err != nil { return cacheStaleness{ NeedsBuild: true, FullRebuild: true, - Reason: "cannot verify conversation types", + Reason: "cannot verify conversation metadata", } } if typesFingerprint != state.ConversationTypesFingerprint { result.HasConversationTypeDrift = true - reasons = append(reasons, "conversation types changed") + reasons = append(reasons, "conversation metadata changed") } // An incremental build can append only new activity rows. If canonical @@ -393,10 +410,9 @@ func cacheNeedsBuildLocked(dbPath, analyticsDir string) cacheStaleness { return result } -// sourceConversationTypesFingerprint hashes (id, conversation_type) for +// sourceConversationTypesFingerprint hashes (id, conversation_type, title) for // conversations with exportable messages inside the committed watermark. The -// NULL normalization must match the conversations Parquet export -// (COALESCE(conversation_type, 'email_thread')) and +// NULL normalization must match the conversations Parquet export and // fingerprintConversationTypesFromSnapshot so an unchanged database always // reproduces the stamped fingerprint. func sourceConversationTypesFingerprint( @@ -404,7 +420,8 @@ func sourceConversationTypesFingerprint( lastMessageID int64, ) (string, error) { rows, err := db.Query(` - SELECT c.id, COALESCE(c.conversation_type, 'email_thread') + SELECT c.id, COALESCE(c.conversation_type, 'email_thread'), + COALESCE(c.title, '') FROM conversations c WHERE EXISTS ( SELECT 1 @@ -416,12 +433,12 @@ func sourceConversationTypesFingerprint( ORDER BY c.id `, lastMessageID) if err != nil { - return "", fmt.Errorf("query conversation types for fingerprint: %w", err) + return "", fmt.Errorf("query conversation metadata for fingerprint: %w", err) } defer func() { _ = rows.Close() }() - fingerprint, err := identityindex.FingerprintConversationTypes(rows) + fingerprint, err := identityindex.FingerprintConversationMetadata(rows) if rowsErr := rows.Err(); rowsErr != nil && err == nil { - return "", fmt.Errorf("iterate conversation types for fingerprint: %w", rowsErr) + return "", fmt.Errorf("iterate conversation metadata for fingerprint: %w", rowsErr) } return fingerprint, err } diff --git a/cmd/msgvault/cmd/create_subset.go b/cmd/msgvault/cmd/create_subset.go index 59d7c66b2..379de6bb3 100644 --- a/cmd/msgvault/cmd/create_subset.go +++ b/cmd/msgvault/cmd/create_subset.go @@ -30,6 +30,7 @@ var ( subsetRows int subsetIncludeIdentity bool subsetIncludeAttributes bool + subsetIncludeProfiles bool ) func init() { @@ -43,14 +44,18 @@ func init() { ) createSubsetCmd.Flags().BoolVar( &subsetIncludeIdentity, "include-identity", false, - "copy full identity clusters and person profiles for included "+ - "participants; exposes identifiers (emails, phone numbers) of "+ + "copy full identity clusters for included participants; exposes "+ + "identifiers (emails, phone numbers) of "+ "linked identities that have no messages in the subset", ) createSubsetCmd.Flags().BoolVar( &subsetIncludeAttributes, "include-attributes", false, "copy person attribute definitions and all current/history values; may expose sensitive values and provenance metadata", ) + createSubsetCmd.Flags().BoolVar( + &subsetIncludeProfiles, "include-profiles", false, + "copy structured profile values, history, media, contact observations, and provenance; may expose sensitive personal data", + ) _ = createSubsetCmd.MarkFlagRequired("output") _ = createSubsetCmd.MarkFlagRequired("rows") rootCmd.AddCommand(createSubsetCmd) @@ -98,11 +103,16 @@ func runCreateSubset(cmd *cobra.Command, args []string) error { fmt.Fprintln(os.Stderr, "WARNING: --include-attributes copies every included person's current and historical attribute values, including sensitive content, provenance references, and actor metadata.") } + if subsetIncludeProfiles { + fmt.Fprintln(os.Stderr, + "WARNING: --include-profiles copies every included person's current and historical structured profile values, media, contact observations, and provenance metadata.") + } result, err := store.CopySubsetWithOptions(srcDBPath, dstDir, subsetRows, store.CopySubsetOptions{ IncludeIdentity: subsetIncludeIdentity, IncludeAttributes: subsetIncludeAttributes, + IncludeProfiles: subsetIncludeProfiles, }) if err != nil { return fmt.Errorf("create subset: %w", err) diff --git a/cmd/msgvault/cmd/repair_encoding.go b/cmd/msgvault/cmd/repair_encoding.go index 9f1a79554..b9548371d 100644 --- a/cmd/msgvault/cmd/repair_encoding.go +++ b/cmd/msgvault/cmd/repair_encoding.go @@ -4,6 +4,7 @@ import ( "compress/zlib" "context" "database/sql" + "errors" "fmt" "io" "os" @@ -45,7 +46,7 @@ charset detection issues in the MIME parser.`, }, } -func runRepairEncodingLocal(cmd *cobra.Command) error { +func runRepairEncodingLocal(cmd *cobra.Command) (runErr error) { ctx := cmd.Context() s, cleanup, err := openWritableStoreAndInit() @@ -54,6 +55,28 @@ func runRepairEncodingLocal(cmd *cobra.Command) error { } defer cleanup() + dbPath := cfg.DatabaseDSN() + analyticsDir := cfg.AnalyticsDir() + usesAnalyticsCache := dateRepairUsesAnalyticsCache(dbPath) + unlockCache := func() error { return nil } + if usesAnalyticsCache { + releaseCacheLocks, err := lockCacheAndInvalidateSyncState(analyticsDir) + if err != nil { + return fmt.Errorf("protect analytics cache for encoding repair: %w", err) + } + released := false + unlockCache = func() error { + if released { + return nil + } + released = true + return wrapError(releaseCacheLocks(), "release analytics cache lock") + } + defer func() { + runErr = errors.Join(runErr, unlockCache()) + }() + } + reembedNeededIDs, err := repairEncoding(s) if err != nil { return err @@ -71,15 +94,26 @@ func runRepairEncodingLocal(cmd *cobra.Command) error { } } - dbPath := cfg.DatabaseDSN() - analyticsDir := cfg.AnalyticsDir() - if _, err := buildCache( - dbPath, - analyticsDir, - true, - analyticsBuilderOverrides(cfg.Analytics), - ); err != nil { - return fmt.Errorf("encoding repair completed, but analytics cache refresh failed: %w", err) + var buildErr error + if usesAnalyticsCache { + _, buildErr = buildCacheLocked( + dbPath, + analyticsDir, + true, + false, + publishLockHeld, + analyticsBuilderOverrides(cfg.Analytics), + ) + } else { + _, buildErr = buildCache( + dbPath, + analyticsDir, + true, + analyticsBuilderOverrides(cfg.Analytics), + ) + } + if buildErr != nil { + return fmt.Errorf("encoding repair completed, but analytics cache refresh failed: %w", buildErr) } fmt.Println("\nAnalytics cache rebuilt.") return nil @@ -435,6 +469,8 @@ func repairMessageFields(s *store.Store, stats *repairStats) (reembedNeededIDs [ return reembedNeededIDs, nil } +const participantDisplayNameRepairSQL = "UPDATE participants SET display_name = ? WHERE id = ?" + func repairDisplayNames(s *store.Store, stats *repairStats) error { // Repair display names in both message_recipients and participants tables tables := []struct { @@ -450,7 +486,7 @@ func repairDisplayNames(s *store.Store, stats *repairStats) error { { name: tableParticipants, query: "SELECT id, display_name FROM participants WHERE display_name IS NOT NULL", - updateStmt: "UPDATE participants SET display_name = ? WHERE id = ?", + updateStmt: participantDisplayNameRepairSQL, }, } @@ -486,6 +522,16 @@ type stringRepair struct { // a single-connection store, beginning a transaction while a SELECT cursor is // still open deadlocks waiting for the connection the cursor holds. func applyStringRepairs(s *store.Store, updateStmt, tableName string, batch []stringRepair) error { + if updateStmt == participantDisplayNameRepairSQL { + repairs := make([]store.ParticipantDisplayNameRepair, 0, len(batch)) + for _, repair := range batch { + repairs = append(repairs, store.ParticipantDisplayNameRepair{ + ParticipantID: repair.id, + DisplayName: repair.value, + }) + } + return s.RepairParticipantDisplayNames(repairs) + } tx, err := s.DB().Begin() if err != nil { return fmt.Errorf("begin transaction: %w", err) diff --git a/cmd/msgvault/cmd/repair_encoding_test.go b/cmd/msgvault/cmd/repair_encoding_test.go index 9f947a55b..41ae505ca 100644 --- a/cmd/msgvault/cmd/repair_encoding_test.go +++ b/cmd/msgvault/cmd/repair_encoding_test.go @@ -3,12 +3,43 @@ package cmd import ( "fmt" "testing" + "unicode/utf8" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.kenn.io/msgvault/internal/testutil" ) +func TestRepairDisplayNamesBumpsParticipantRevisionWithTheRepair(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + testutil.SkipIfPostgres(t, + "inserts invalid UTF-8 bytes into a TEXT column; PostgreSQL rejects them") + st := testutil.NewTestStore(t) + + _, err := st.DB().Exec(` + INSERT INTO participants (email_address, display_name) + VALUES (?, ?) + `, "repair-display@example.com", "Repair\xffName") + require.NoError(err) + before, err := st.ParticipantDisplayNameRevision() + require.NoError(err) + + stats := &repairStats{} + require.NoError(repairDisplayNames(st, stats)) + after, err := st.ParticipantDisplayNameRevision() + require.NoError(err) + assert.Equal(before+1, after, + "the participant repair and its cache revision must commit together") + + var repaired string + require.NoError(st.DB().QueryRow(` + SELECT display_name FROM participants + WHERE email_address = ? + `, "repair-display@example.com").Scan(&repaired)) + assert.True(utf8.ValidString(repaired)) +} + // TestRepairOtherStrings_LogsScanErrors verifies that scan errors during // repairOtherStrings are counted in stats.skippedRows rather than silently // swallowed. We trigger scan errors by recreating the labels table with a diff --git a/cmd/msgvault/cmd/serve.go b/cmd/msgvault/cmd/serve.go index 8287fa76f..b39115cd9 100644 --- a/cmd/msgvault/cmd/serve.go +++ b/cmd/msgvault/cmd/serve.go @@ -1096,6 +1096,8 @@ var _ api.CLIDedupDeleteStore = (*storeAPIAdapter)(nil) var _ api.ContextCLIDedupDeleteStore = (*storeAPIAdapter)(nil) var _ api.IdentityLinkStore = (*storeAPIAdapter)(nil) var _ api.PersonProfileStore = (*storeAPIAdapter)(nil) +var _ api.PersonProfileValueStore = (*storeAPIAdapter)(nil) +var _ api.CommunicationServiceStore = (*storeAPIAdapter)(nil) var _ api.AttributeDefinitionStore = (*storeAPIAdapter)(nil) var _ api.PersonAttributeStore = (*storeAPIAdapter)(nil) var _ api.IdentityCacheRefresher = (*storeAPIAdapter)(nil) @@ -1895,6 +1897,44 @@ func (a *storeAPIAdapter) PersonForParticipantsContext( return a.store.PersonForParticipantsContext(ctx, participantIDs) } +func (a *storeAPIAdapter) GetPersonProfileContext( + ctx context.Context, personID int64, +) (*store.PersonProfile, error) { + return a.store.GetPersonProfileContext(ctx, personID) +} + +func (a *storeAPIAdapter) ApplyPersonProfilePatchContext( + ctx context.Context, + personID, expectedRevision int64, + patch store.PersonProfilePatch, +) (*store.PersonProfile, error) { + return a.store.ApplyPersonProfilePatchContext(ctx, personID, expectedRevision, patch) +} + +func (a *storeAPIAdapter) GetPersonProfileHistoryContext( + ctx context.Context, personID int64, +) (*store.PersonProfileHistory, error) { + return a.store.GetPersonProfileHistoryContext(ctx, personID) +} + +func (a *storeAPIAdapter) ReadPersonMediaDataContext( + ctx context.Context, personID, mediaID int64, +) ([]byte, string, error) { + return a.store.ReadPersonMediaDataContext(ctx, personID, mediaID) +} + +func (a *storeAPIAdapter) ListCommunicationServicesContext( + ctx context.Context, includeInactive bool, +) ([]store.CommunicationService, error) { + return a.store.ListCommunicationServicesContext(ctx, includeInactive) +} + +func (a *storeAPIAdapter) EnsureCommunicationServiceContext( + ctx context.Context, input store.CommunicationServiceInput, +) (*store.CommunicationService, bool, error) { + return a.store.EnsureCommunicationServiceContext(ctx, input) +} + func (a *storeAPIAdapter) ListAttributeDefinitionsContext( ctx context.Context, filter store.AttributeDefinitionFilter, ) ([]store.AttributeDefinition, error) { diff --git a/cmd/msgvault/cmd/serve_api_wiring_test.go b/cmd/msgvault/cmd/serve_api_wiring_test.go index b96762f34..0bf98a932 100644 --- a/cmd/msgvault/cmd/serve_api_wiring_test.go +++ b/cmd/msgvault/cmd/serve_api_wiring_test.go @@ -1,6 +1,10 @@ package cmd import ( + "fmt" + "log/slog" + "net/http" + "net/http/httptest" "testing" "github.com/stretchr/testify/assert" @@ -34,3 +38,37 @@ func TestStoreAPIAdapterExposesFileMetadataCatalog(t *testing.T) { requirements.NoError(err) assertions.Empty(files) } + +func TestStoreAPIAdapterServesProfileAndCommunicationServiceRoutes(t *testing.T) { + requirements := require.New(t) + st := testutil.NewTestStore(t) + participantID, err := st.EnsureParticipantByIdentifier( + "email", "production-adapter@example.test", "Production Adapter", + ) + requirements.NoError(err) + person, _, err := st.CreatePersonFromParticipant(participantID) + requirements.NoError(err) + + srv := api.NewServerWithOptions(api.ServerOptions{ + Config: &config.Config{}, + Store: &storeAPIAdapter{store: st}, + Logger: slog.New(slog.DiscardHandler), + }) + + for _, test := range []struct { + name string + path string + }{ + {"communication services", "/api/v1/communication-services"}, + {"structured profile", fmt.Sprintf("/api/v1/persons/%d/profile", person.ID)}, + } { + t.Run(test.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, test.path, nil) + response := httptest.NewRecorder() + + srv.Router().ServeHTTP(response, request) + + require.Equal(t, http.StatusOK, response.Code, response.Body.String()) + }) + } +} diff --git a/cmd/msgvault/cmd/serve_test.go b/cmd/msgvault/cmd/serve_test.go index 4492a7e12..b26330f0f 100644 --- a/cmd/msgvault/cmd/serve_test.go +++ b/cmd/msgvault/cmd/serve_test.go @@ -255,7 +255,10 @@ func TestRunServeAutoSelectsAPIPortWhenUnconfigured(t *testing.T) { // Discover the auto-selected port the same way clients do: through the // daemon runtime record, not the configured port (which is 0). - rt, ready, err := waitForDaemonRuntime(ctx, dataDir, 15*time.Second, daemonRuntimeReady, errCh) + // A fresh Windows runner can need more than 15 seconds to initialize the + // full schema while the CLI package shards compete for CPU and disk I/O. + // This test checks port discovery, not startup performance. + rt, ready, err := waitForDaemonRuntime(ctx, dataDir, 45*time.Second, daemonRuntimeReady, errCh) require.NoError(err, "wait for daemon runtime record") require.True(ready, "daemon runtime record did not become ready") assert.NotZero(rt.Port, "runtime record must record the bound ephemeral port") diff --git a/internal/api/attribute_definitions.go b/internal/api/attribute_definitions.go index bdd98026a..113e9cdfc 100644 --- a/internal/api/attribute_definitions.go +++ b/internal/api/attribute_definitions.go @@ -351,7 +351,7 @@ func addAttributeDefinitionIDParameter(operation *huma.Operation) { func addAttributeDefinitionIfMatchParameter(operation *huma.Operation) { operation.Parameters = append(operation.Parameters, &huma.Param{ - Name: "If-Match", In: "header", Required: true, + Name: ifMatchHeaderName, In: "header", Required: true, Description: "Strong ETag returned by the latest definition read", Schema: &huma.Schema{Type: huma.TypeString}, }) @@ -379,7 +379,7 @@ func attributeDefinitionID(w http.ResponseWriter, r *http.Request) (int64, bool) func attributeDefinitionIfMatch( w http.ResponseWriter, r *http.Request, id int64, ) (int64, bool) { - values := r.Header.Values("If-Match") + values := r.Header.Values(ifMatchHeaderName) if len(values) == 0 || (len(values) == 1 && strings.TrimSpace(values[0]) == "") { writeError(w, http.StatusPreconditionRequired, "if_match_required", "If-Match is required") diff --git a/internal/api/communication_services.go b/internal/api/communication_services.go new file mode 100644 index 000000000..80ea868a7 --- /dev/null +++ b/internal/api/communication_services.go @@ -0,0 +1,145 @@ +package api + +import ( + "context" + "errors" + "net/http" + + "github.com/danielgtaylor/huma/v2" + "go.kenn.io/msgvault/internal/store" +) + +type CommunicationServiceStore interface { + ListCommunicationServicesContext( + ctx context.Context, includeInactive bool, + ) ([]store.CommunicationService, error) + EnsureCommunicationServiceContext( + ctx context.Context, input store.CommunicationServiceInput, + ) (*store.CommunicationService, bool, error) +} + +type CommunicationServicesResponse struct { + Services []store.CommunicationService `json:"services"` +} + +type CreateCommunicationServiceRequest struct { + Slug string `json:"slug"` + DisplayLabel string `json:"display_label"` + Aliases []string `json:"aliases,omitempty"` + ScopePolicy string `json:"scope_policy" enum:"none,optional,required"` + DefaultScopeKind *string `json:"default_scope_kind,omitempty"` + Normalization string `json:"normalization" enum:"none,lower,email,phone_e164,strip_at_lower,by_address_kind"` + NormalizationVersion int `json:"normalization_version,omitempty"` + URIScheme *string `json:"uri_scheme,omitempty"` + ProfileURLTemplate *string `json:"profile_url_template,omitempty"` +} + +func (s *Server) registerCommunicationServiceRoutes(api huma.API) { + list := rawAPIV1Operation( + "listCommunicationServices", http.MethodGet, "/communication-services", + "List communication services", + ) + list.Description = "Lists the small open service catalog without pagination, including aliases and normalization policy." + list.Parameters = append(list.Parameters, &huma.Param{ + Name: "include_inactive", In: "query", + Description: "Include inactive catalog entries", + Schema: &huma.Schema{Type: huma.TypeBoolean, Default: false}, + }) + list.Responses = jsonResponsesFor[CommunicationServicesResponse](api) + addErrorResponses(api, list.Responses, http.StatusBadRequest, http.StatusServiceUnavailable) + registerRawHumaRoute(api, list, s.handleListCommunicationServices) + + create := rawAPIV1Operation( + "createCommunicationService", http.MethodPost, "/communication-services", + "Register a communication service", + ) + create.Description = "Registers an unknown or custom service without a schema migration. Re-registering a slug is idempotent." + create.RequestBody = jsonRequestBodyFor[CreateCommunicationServiceRequest](api) + create.Responses = jsonResponsesFor[store.CommunicationService](api) + addErrorResponses(api, create.Responses, http.StatusBadRequest, http.StatusConflict, + http.StatusNotFound, http.StatusServiceUnavailable) + registerRawHumaRoute(api, create, s.handleCreateCommunicationService) +} + +func (s *Server) handleListCommunicationServices(w http.ResponseWriter, r *http.Request) { + servicesStore, ok := s.communicationServiceStore(w) + if !ok { + return + } + includeInactive, _, err := queryBool(r, "include_inactive") + if err != nil { + s.rejectBadParam(w, err) + return + } + services, err := servicesStore.ListCommunicationServicesContext( + r.Context(), includeInactive, + ) + if err != nil { + s.writeCommunicationServiceError(w, err) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, CommunicationServicesResponse{Services: services}) +} + +func (s *Server) handleCreateCommunicationService(w http.ResponseWriter, r *http.Request) { + servicesStore, ok := s.communicationServiceStore(w) + if !ok { + return + } + var request CreateCommunicationServiceRequest + if !decodePersonRequest(w, r, &request) { + return + } + if request.NormalizationVersion == 0 { + request.NormalizationVersion = 1 + } + service, _, err := servicesStore.EnsureCommunicationServiceContext( + r.Context(), store.CommunicationServiceInput{ + Slug: request.Slug, DisplayLabel: request.DisplayLabel, + Aliases: request.Aliases, ScopePolicy: request.ScopePolicy, + DefaultScopeKind: request.DefaultScopeKind, + Normalization: request.Normalization, + NormalizationVersion: request.NormalizationVersion, + URIScheme: request.URIScheme, + ProfileURLTemplate: request.ProfileURLTemplate, + }, + ) + if err != nil { + s.writeCommunicationServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, service) +} + +func (s *Server) communicationServiceStore( + w http.ResponseWriter, +) (CommunicationServiceStore, bool) { + services, ok := s.store.(CommunicationServiceStore) + if !ok { + writeError( + w, http.StatusServiceUnavailable, "communication_services_unavailable", + "Communication services are unavailable", + ) + } + return services, ok +} + +func (s *Server) writeCommunicationServiceError(w http.ResponseWriter, err error) { + if s.writeIfContextError(w, err) { + return + } + switch { + case errors.Is(err, store.ErrInvalidServiceSlug), + errors.Is(err, store.ErrInvalidScopePolicy), + errors.Is(err, store.ErrInvalidNormalization): + writeError(w, http.StatusBadRequest, "invalid_communication_service", err.Error()) + case errors.Is(err, store.ErrServiceAliasConflict): + writeError(w, http.StatusConflict, "service_alias_conflict", err.Error()) + case errors.Is(err, store.ErrServiceNotFound): + writeError(w, http.StatusNotFound, "communication_service_not_found", err.Error()) + default: + s.logger.Error("communication service operation failed", "error", err) + writeError(w, http.StatusInternalServerError, "communication_service_failed", "Communication service operation failed") + } +} diff --git a/internal/api/communication_services_test.go b/internal/api/communication_services_test.go new file mode 100644 index 000000000..ba8a23a6c --- /dev/null +++ b/internal/api/communication_services_test.go @@ -0,0 +1,72 @@ +package api + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListCommunicationServicesReturnsSeededCatalogWithAliases(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + server, _ := newProfileTestServer(t) + + recorder := doRequest(t, server, http.MethodGet, "/api/v1/communication-services", nil, nil) + require.Equal(http.StatusOK, recorder.Code, recorder.Body.String()) + var response CommunicationServicesResponse + require.NoError(json.Unmarshal(recorder.Body.Bytes(), &response), recorder.Body.String()) + assert.GreaterOrEqual(len(response.Services), 24) + + aliasesBySlug := make(map[string][]string, len(response.Services)) + for _, service := range response.Services { + aliasesBySlug[service.Slug] = service.Aliases + } + assert.Contains(aliasesBySlug["x"], "twitter") + assert.Contains(aliasesBySlug["bluesky"], "bsky") + assert.Contains(aliasesBySlug["google-messages"], "gmessages") +} + +func TestCreateCommunicationServiceRegistersUnknownBridge(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + server, _ := newProfileTestServer(t) + body := []byte(`{ + "slug":"example-bridge", + "display_label":"Example Bridge", + "aliases":["examplebridge"], + "scope_policy":"optional", + "default_scope_kind":"account", + "normalization":"lower" + }`) + created := doRequest(t, server, http.MethodPost, "/api/v1/communication-services", body, nil) + require.Equal(http.StatusOK, created.Code, created.Body.String()) + + again := doRequest(t, server, http.MethodPost, "/api/v1/communication-services", body, nil) + assert.Equal(http.StatusOK, again.Code, "re-registering the same slug is idempotent") + listed := doRequest(t, server, http.MethodGet, "/api/v1/communication-services", nil, nil) + require.Equal(http.StatusOK, listed.Code, listed.Body.String()) + assert.Contains(listed.Body.String(), "example-bridge") +} + +func TestCreateCommunicationServiceValidatesInput(t *testing.T) { + assert := assert.New(t) + server, _ := newProfileTestServer(t) + cases := []struct { + name string + body string + want int + }{ + {"invalid slug", `{"slug":"Example Bridge","display_label":"X","scope_policy":"none","normalization":"lower"}`, http.StatusBadRequest}, + {"invalid scope policy", `{"slug":"example","display_label":"X","scope_policy":"sometimes","normalization":"lower"}`, http.StatusBadRequest}, + {"invalid normalization", `{"slug":"example","display_label":"X","scope_policy":"none","normalization":"soundex"}`, http.StatusBadRequest}, + {"alias belongs to another service", `{"slug":"example","display_label":"X","aliases":["twitter"],"scope_policy":"none","normalization":"lower"}`, http.StatusConflict}, + } + for _, tc := range cases { + recorder := doRequest(t, server, http.MethodPost, "/api/v1/communication-services", + []byte(tc.body), nil) + assert.Equal(tc.want, recorder.Code, "%s: %s", tc.name, recorder.Body.String()) + } +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 76f47e553..4253c980f 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -48,7 +48,7 @@ func defaultCORSAllowedMethods() []string { // updates, X-Request-Id the idempotency key for task creation. func defaultCORSAllowedHeaders() []string { return []string{ - "Accept", "Authorization", "Content-Type", "If-Match", + "Accept", "Authorization", "Content-Type", ifMatchHeaderName, "X-API-Key", "X-Request-Id", csrfHeaderName, } } @@ -56,7 +56,10 @@ func defaultCORSAllowedHeaders() []string { // corsExposedHeaders is the Access-Control-Expose-Headers value: ETag is the // only non-safelisted response header clients read (settings and saved-view // concurrency tokens). -const corsExposedHeaders = "ETag" +const ( + corsExposedHeaders = "ETag" + ifMatchHeaderName = "If-Match" +) // CORSMiddleware returns a middleware that handles CORS headers. // diff --git a/internal/api/openapi.go b/internal/api/openapi.go index 52cc6865e..595c87e9c 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -178,7 +178,13 @@ import ( // timeline routes, and adds a typed terminal error variant to CLI identity // discovery NDJSON streams. Additive (minor bump): existing progress/result // events and relationship requests without identity filters are unchanged. -const APISchemaVersion = "1.36.0" +// 1.37.0 adds typed structured-person profile read, patch, and history routes, +// plus an open communication-service catalog. Additive (minor bump): existing +// person and source-identity routes keep their current contracts. +// 1.38.0 adds authenticated raw access to inline person-profile media bytes. +// Additive (minor bump): existing profile metadata and patch contracts are +// unchanged, and URI-only media remains metadata-only. +const APISchemaVersion = "1.38.0" // OpenAPIDocument builds the API schema from the same Huma route registration // used by the daemon. It binds no socket and needs no database. @@ -542,6 +548,21 @@ func applyClientCodegenExtensions(doc *huma.OpenAPI) { }) } for schemaName, properties := range map[string]map[string][]any{ + "CreateCommunicationServiceRequest": { + "normalization": { + "CreateCommunicationServiceRequestNormalizationNone", + "CreateCommunicationServiceRequestNormalizationLower", + "CreateCommunicationServiceRequestNormalizationEmail", + "CreateCommunicationServiceRequestNormalizationPhoneE164", + "CreateCommunicationServiceRequestNormalizationStripAtLower", + "CreateCommunicationServiceRequestNormalizationByAddressKind", + }, + "scope_policy": { + "CreateCommunicationServiceRequestScopePolicyNone", + "CreateCommunicationServiceRequestScopePolicyOptional", + "CreateCommunicationServiceRequestScopePolicyRequired", + }, + }, "ExploreCacheUnavailableResponse": { "readiness": {"ExploreCacheUnavailableResponseReadinessAbsent", "ExploreCacheUnavailableResponseReadinessInterrupted", "ExploreCacheUnavailableResponseReadinessStaleSchema", "ExploreCacheUnavailableResponseReadinessDrifted"}, }, diff --git a/internal/api/openapi_test.go b/internal/api/openapi_test.go index 52821910c..0daac9f0e 100644 --- a/internal/api/openapi_test.go +++ b/internal/api/openapi_test.go @@ -265,8 +265,8 @@ func TestOpenAPIPersonAttributeContract(t *testing.T) { require := require.New(t) assert := assert.New(t) - assert.Equal("1.36.0", APISchemaVersion, - "source-scoped identities are additive to the attribute schema release") + assert.Equal("1.38.0", APISchemaVersion, + "structured profiles are additive to the attribute schema release") doc := OpenAPIDocument() definitions := doc.Paths["/api/v1/attribute-definitions"] @@ -286,6 +286,83 @@ func TestOpenAPIPersonAttributeContract(t *testing.T) { assert.NotNil(value.Delete, "person attribute clear operation") } +func TestOpenAPIPersonProfilePatchUsesWritableEnvelopeShape(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + doc := OpenAPIDocument() + + path := doc.Paths["/api/v1/persons/{id}/profile"] + require.NotNil(path) + require.NotNil(path.Patch) + require.NotNil(path.Patch.RequestBody) + media := path.Patch.RequestBody.Content["application/json"] + require.NotNil(media) + require.NotNil(media.Schema) + assert.Equal("#/components/schemas/PersonProfilePatchRequest", media.Schema.Ref) + + schemas := doc.Components.Schemas.Map() + request := schemas["PersonProfilePatchRequest"] + require.NotNil(request) + for _, patchName := range []string{ + "PersonNamePatchRequest", "PersonContactPointPatchRequest", + "PersonAddressPatchRequest", "PersonDatePatchRequest", + "PersonCategoryPatchRequest", "PersonMediaPatchRequest", + } { + assert.NotNil(schemas[patchName], patchName) + } + envelope := schemas["ValueEnvelopeInput"] + require.NotNil(envelope) + for _, serverOwned := range []string{"id", "created_at", "updated_at", "superseded_at"} { + assert.NotContains(envelope.Properties, serverOwned) + assert.NotContains(envelope.Required, serverOwned) + } + assert.Contains(envelope.Required, "source") + require.NotNil(envelope.Properties["ordinal"]) + require.NotNil(envelope.Properties["ordinal"].Minimum) + assert.Zero(*envelope.Properties["ordinal"].Minimum) + + for schemaName, optionalFields := range map[string][]string{ + "PersonNameInputRequest": {"original_value"}, + "PersonAddressInputRequest": {"original_value"}, + "PersonDateInputRequest": {"date", "original_value"}, + "PersonMediaInputRequest": {"original_value"}, + } { + input := schemas[schemaName] + require.NotNil(input, schemaName) + for _, field := range optionalFields { + assert.NotContains(input.Required, field, "%s.%s", schemaName, field) + } + } +} + +func TestOpenAPIPersonProfileMediaContentContract(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + + assert.Equal("1.38.0", APISchemaVersion, + "raw profile media content is an additive schema release") + doc := OpenAPIDocument() + path := doc.Paths["/api/v1/persons/{id}/profile/media/{media_id}/content"] + require.NotNil(path) + require.NotNil(path.Get) + assert.Equal("getPersonProfileMediaContent", path.Get.OperationID) + require.Len(path.Get.Security, 1) + _, secured := path.Get.Security[0]["apiKey"] + assert.True(secured) + require.Len(path.Get.Parameters, 2) + assert.Equal("id", path.Get.Parameters[0].Name) + assert.Equal("media_id", path.Get.Parameters[1].Name) + response := path.Get.Responses["200"] + require.NotNil(response) + binary := response.Content["*/*"] + require.NotNil(binary) + require.NotNil(binary.Schema) + assert.Equal("binary", binary.Schema.Format) + for _, status := range []string{"400", "401", "404", "500", "503"} { + assert.NotNil(path.Get.Responses[status], status) + } +} + func TestOpenAPIMeetingImportContract(t *testing.T) { require := require.New(t) assert := assert.New(t) @@ -293,8 +370,9 @@ func TestOpenAPIMeetingImportContract(t *testing.T) { // Pinned so that anyone bumping the schema version has to come here and // confirm the meeting-import contract below still holds. Meeting import // shipped in 1.33.0; the feed added in 1.34.0, the attributes added in - // 1.35.0, and source-scoped identities added in 1.36.0 did not touch it. - assert.Equal("1.36.0", APISchemaVersion, "meeting import is an additive schema release") + // 1.35.0, source-scoped identities added in 1.36.0, and structured profiles + // added in 1.37.0, and raw profile media added in 1.38.0 did not touch it. + assert.Equal("1.38.0", APISchemaVersion, "meeting import is an additive schema release") doc := OpenAPIDocument() path := doc.Paths["/api/v1/import/meeting"] @@ -525,6 +603,32 @@ func TestOpenAPIDocumentsAllExplorationOperations(t *testing.T) { } } +func TestOpenAPIClientServiceEnumsPreserveExistingGoNames(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + schema := openAPIClientDocument().Components.Schemas.Map()["CreateCommunicationServiceRequest"] + requirements.NotNil(schema) + + for property, want := range map[string][]any{ + "normalization": { + "CreateCommunicationServiceRequestNormalizationNone", + "CreateCommunicationServiceRequestNormalizationLower", + "CreateCommunicationServiceRequestNormalizationEmail", + "CreateCommunicationServiceRequestNormalizationPhoneE164", + "CreateCommunicationServiceRequestNormalizationStripAtLower", + "CreateCommunicationServiceRequestNormalizationByAddressKind", + }, + "scope_policy": { + "CreateCommunicationServiceRequestScopePolicyNone", + "CreateCommunicationServiceRequestScopePolicyOptional", + "CreateCommunicationServiceRequestScopePolicyRequired", + }, + } { + requirements.NotNil(schema.Properties[property], property) + assertions.Equal(want, schema.Properties[property].Extensions["x-enum-names"], property) + } +} + func TestOpenAPIExplorationUsesStructuredUnavailableUnion(t *testing.T) { requirements := require.New(t) assertions := assert.New(t) diff --git a/internal/api/person_profile_values.go b/internal/api/person_profile_values.go new file mode 100644 index 000000000..698119010 --- /dev/null +++ b/internal/api/person_profile_values.go @@ -0,0 +1,440 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "mime" + "net/http" + "strconv" + "strings" + + "github.com/danielgtaylor/huma/v2" + "go.kenn.io/msgvault/internal/store" +) + +const MaxPersonProfilePatchBytes = 12 << 20 + +type PersonProfileValueStore interface { + GetPersonProfileContext(ctx context.Context, personID int64) (*store.PersonProfile, error) + ApplyPersonProfilePatchContext( + ctx context.Context, + personID, expectedRevision int64, + patch store.PersonProfilePatch, + ) (*store.PersonProfile, error) + GetPersonProfileHistoryContext( + ctx context.Context, personID int64, + ) (*store.PersonProfileHistory, error) + ReadPersonMediaDataContext( + ctx context.Context, personID, mediaID int64, + ) ([]byte, string, error) +} + +// StructuredPersonProfile gives the aggregate store model a distinct OpenAPI +// component name. The query package already exports an unrelated +// PersonProfile, and huma component names are package-agnostic. +type StructuredPersonProfile store.PersonProfile + +// ValueEnvelopeInput is the client-writable part of store.ValueEnvelope. +// Database IDs and transaction timestamps are response-only fields, so the +// PATCH schema must not require clients to fabricate them. +type ValueEnvelopeInput store.ValueEnvelopeInput + +type PersonProfilePatchRequest struct { + Names *PersonNamePatchRequest `json:"names,omitempty"` + ContactPoints *PersonContactPointPatchRequest `json:"contact_points,omitempty"` + Addresses *PersonAddressPatchRequest `json:"addresses,omitempty"` + Dates *PersonDatePatchRequest `json:"dates,omitempty"` + Categories *PersonCategoryPatchRequest `json:"categories,omitempty"` + Media *PersonMediaPatchRequest `json:"media,omitempty"` +} + +type PersonNamePatchRequest struct { + Add []PersonNameInputRequest `json:"add,omitempty"` + Supersede []int64 `json:"supersede,omitempty"` +} + +type PersonNameInputRequest struct { + NameKind store.PersonNameKind `json:"name_kind"` + Formatted *string `json:"formatted,omitempty"` + FamilyName *string `json:"family_name,omitempty"` + GivenName *string `json:"given_name,omitempty"` + AdditionalNames *string `json:"additional_names,omitempty"` + HonorificPrefixes *string `json:"honorific_prefixes,omitempty"` + HonorificSuffixes *string `json:"honorific_suffixes,omitempty"` + SecondarySurname *string `json:"secondary_surname,omitempty"` + Generation *string `json:"generation,omitempty"` + Language *string `json:"language,omitempty"` + Script *string `json:"script,omitempty"` + PhoneticSystem *string `json:"phonetic_system,omitempty"` + PhoneticScript *string `json:"phonetic_script,omitempty"` + SortAs *string `json:"sort_as,omitempty"` + IsDerived bool `json:"is_derived,omitempty"` + OriginalValue string `json:"original_value,omitempty"` + Envelope ValueEnvelopeInput `json:"envelope"` +} + +type PersonContactPointPatchRequest struct { + Add []PersonContactPointInputRequest `json:"add,omitempty"` + Supersede []int64 `json:"supersede,omitempty"` +} + +type PersonContactPointInputRequest struct { + AddressKind store.ContactAddressKind `json:"address_kind"` + ServiceSlug *string `json:"service_slug,omitempty"` + ScopeKind *string `json:"scope_kind,omitempty"` + ScopeValue *string `json:"scope_value,omitempty"` + OriginalValue string `json:"original_value"` + URI *string `json:"uri,omitempty"` + Envelope ValueEnvelopeInput `json:"envelope"` +} + +type PersonAddressPatchRequest struct { + Add []PersonAddressInputRequest `json:"add,omitempty"` + Supersede []int64 `json:"supersede,omitempty"` +} + +type PersonAddressInputRequest struct { + AddressKind store.PersonAddressKind `json:"address_kind"` + PostOfficeBox *string `json:"post_office_box,omitempty"` + ExtendedAddress *string `json:"extended_address,omitempty"` + StreetAddress *string `json:"street_address,omitempty"` + Locality *string `json:"locality,omitempty"` + Region *string `json:"region,omitempty"` + PostalCode *string `json:"postal_code,omitempty"` + CountryName *string `json:"country_name,omitempty"` + ExtendedComponents *string `json:"extended_components,omitempty"` + FreeText *string `json:"free_text,omitempty"` + Label *string `json:"label,omitempty"` + GeoURI *string `json:"geo_uri,omitempty"` + Timezone *string `json:"timezone,omitempty"` + CountryCode *string `json:"country_code,omitempty"` + PlaceURI *string `json:"place_uri,omitempty"` + OriginalValue string `json:"original_value,omitempty"` + Envelope ValueEnvelopeInput `json:"envelope"` +} + +type PersonDatePatchRequest struct { + Add []PersonDateInputRequest `json:"add,omitempty"` + Supersede []int64 `json:"supersede,omitempty"` +} + +type PersonDateInputRequest struct { + DateKind store.PersonDateKind `json:"date_kind"` + Label *string `json:"label,omitempty"` + Date store.PartialDate `json:"date,omitzero"` + DateText *string `json:"date_text,omitempty"` + CalendarScale *string `json:"calendar_scale,omitempty"` + OriginalValue string `json:"original_value,omitempty"` + Envelope ValueEnvelopeInput `json:"envelope"` +} + +type PersonCategoryPatchRequest struct { + Add []PersonCategoryInputRequest `json:"add,omitempty"` + Supersede []int64 `json:"supersede,omitempty"` +} + +type PersonCategoryInputRequest struct { + OriginalValue string `json:"original_value"` + Envelope ValueEnvelopeInput `json:"envelope"` +} + +type PersonMediaPatchRequest struct { + Add []PersonMediaInputRequest `json:"add,omitempty"` + Supersede []int64 `json:"supersede,omitempty"` +} + +type PersonMediaInputRequest struct { + MediaKind store.PersonMediaKind `json:"media_kind"` + MediaType *string `json:"media_type,omitempty"` + URI *string `json:"uri,omitempty"` + Data []byte `json:"data,omitempty"` + OriginalValue string `json:"original_value,omitempty"` + Envelope ValueEnvelopeInput `json:"envelope"` +} + +func (s *Server) registerPersonProfileValueRoutes(api huma.API) { + get := rawAPIV1Operation( + "getPersonStructuredProfile", http.MethodGet, "/persons/{id}/profile", + "Get a person's current structured profile", + ) + get.Description = "Returns only current structured values at one person revision. " + + "Superseded values and archive observations are available from the separate history endpoint." + addPersonIDParameter(&get) + get.Responses = jsonResponsesFor[StructuredPersonProfile](api) + addPersonETagHeader(get.Responses[httpStatusKey(http.StatusOK)]) + addErrorResponses(api, get.Responses, http.StatusBadRequest, http.StatusNotFound, + http.StatusServiceUnavailable) + registerRawHumaRoute(api, get, s.handleGetPersonStructuredProfile) + + patch := rawAPIV1Operation( + "patchPersonStructuredProfile", http.MethodPatch, "/persons/{id}/profile", + "Atomically patch a person's structured profile", + ) + patch.Description = "Applies up to 200 explicit adds and supersedes atomically under If-Match. " + + "One patch advances the person revision once. Superseding closes world and transaction time without deletion." + addPersonIDParameter(&patch) + addPersonIfMatchParameter(&patch) + patch.RequestBody = jsonRequestBodyFor[PersonProfilePatchRequest](api) + patch.Responses = jsonResponsesFor[StructuredPersonProfile](api) + addPersonETagHeader(patch.Responses[httpStatusKey(http.StatusOK)]) + addErrorResponses(api, patch.Responses, http.StatusBadRequest, http.StatusConflict, + http.StatusNotFound, http.StatusPreconditionRequired, http.StatusRequestEntityTooLarge, + http.StatusServiceUnavailable) + registerRawHumaRoute(api, patch, s.handlePatchPersonStructuredProfile) + + history := rawAPIV1Operation( + "getPersonProfileHistory", http.MethodGet, "/persons/{id}/profile/history", + "Get a person's structured profile history", + ) + history.Description = "Returns current and superseded structured values plus source-linked observations " + + "for every participant bound to the person." + addPersonIDParameter(&history) + history.Responses = jsonResponsesFor[store.PersonProfileHistory](api) + addErrorResponses(api, history.Responses, http.StatusBadRequest, http.StatusNotFound, + http.StatusServiceUnavailable) + registerRawHumaRoute(api, history, s.handleGetPersonProfileHistory) + + mediaContent := rawAPIV1Operation( + "getPersonProfileMediaContent", http.MethodGet, + "/persons/{id}/profile/media/{media_id}/content", + "Download stored inline content for one person profile media value", + ) + mediaContent.Description = "Returns the exact inline bytes stored for one media value. " + + "URI-only values have no local content and return 404." + addPersonIDParameter(&mediaContent) + mediaContent.Parameters = append(mediaContent.Parameters, pathNamedIntegerParam( + "media_id", "Structured person profile media value ID", + )) + mediaContent.Responses = binaryResponsesFor(api, "*/*", + http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, + http.StatusInternalServerError, http.StatusServiceUnavailable, + ) + registerRawHumaRoute(api, mediaContent, s.handleGetPersonProfileMediaContent) +} + +func (s *Server) handleGetPersonStructuredProfile(w http.ResponseWriter, r *http.Request) { + profiles, ok := s.personProfileValueStore(w) + if !ok { + return + } + id, ok := personProfileID(w, r) + if !ok { + return + } + profile, err := profiles.GetPersonProfileContext(r.Context(), id) + if err != nil { + s.writePersonProfileValueError(w, err) + return + } + writePersonStructuredProfile(w, http.StatusOK, profile) +} + +func (s *Server) handlePatchPersonStructuredProfile(w http.ResponseWriter, r *http.Request) { + profiles, ok := s.personProfileValueStore(w) + if !ok { + return + } + id, ok := personProfileID(w, r) + if !ok { + return + } + revision, ok := personIfMatch(w, r, id) + if !ok { + return + } + patch, ok := decodeProfilePatchRequest(w, r) + if !ok { + return + } + profile, err := profiles.ApplyPersonProfilePatchContext( + r.Context(), id, revision, patch, + ) + if err != nil { + s.writePersonProfileValueError(w, err) + return + } + writePersonStructuredProfile(w, http.StatusOK, profile) +} + +func (s *Server) handleGetPersonProfileHistory(w http.ResponseWriter, r *http.Request) { + profiles, ok := s.personProfileValueStore(w) + if !ok { + return + } + id, ok := personProfileID(w, r) + if !ok { + return + } + history, err := profiles.GetPersonProfileHistoryContext(r.Context(), id) + if err != nil { + s.writePersonProfileValueError(w, err) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, history) +} + +func (s *Server) handleGetPersonProfileMediaContent(w http.ResponseWriter, r *http.Request) { + profiles, ok := s.personProfileValueStore(w) + if !ok { + return + } + personID, ok := personProfileID(w, r) + if !ok { + return + } + mediaID, err := strconv.ParseInt(r.PathValue("media_id"), 10, 64) + if err != nil || mediaID <= 0 { + writeError(w, http.StatusBadRequest, "invalid_profile_media_id", + "Person profile media ID must be a positive integer") + return + } + data, mediaType, err := profiles.ReadPersonMediaDataContext( + r.Context(), personID, mediaID, + ) + if err != nil { + switch { + case s.writeIfContextError(w, err): + return + case errors.Is(err, store.ErrProfileValueNotFound): + writeError(w, http.StatusNotFound, "profile_media_not_found", + "Person profile media value not found") + case errors.Is(err, store.ErrPersonMediaNoData): + writeError(w, http.StatusNotFound, "profile_media_content_unavailable", + "Person profile media content is not available") + default: + s.logger.Error("person profile media read failed", "error", err, + "person_id", personID, "media_id", mediaID) + writeError(w, http.StatusInternalServerError, "person_profile_media_failed", + "Person profile media content could not be read") + } + return + } + mediaType = strings.TrimSpace(mediaType) + if _, _, parseErr := mime.ParseMediaType(mediaType); mediaType == "" || parseErr != nil { + mediaType = "application/octet-stream" + } + w.Header().Set("Content-Type", mediaType) + w.Header().Set("Content-Length", strconv.Itoa(len(data))) + w.Header().Set("Content-Disposition", "attachment") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Cache-Control", "no-store") + if _, err := w.Write(data); err != nil { + s.logger.Error("person profile media write failed", "error", err, + "person_id", personID, "media_id", mediaID) + } +} + +func (s *Server) personProfileValueStore( + w http.ResponseWriter, +) (PersonProfileValueStore, bool) { + profiles, ok := s.store.(PersonProfileValueStore) + if !ok { + writeError( + w, http.StatusServiceUnavailable, "profile_values_unavailable", + "Structured person profile values are unavailable", + ) + } + return profiles, ok +} + +func (s *Server) writePersonProfileValueError(w http.ResponseWriter, err error) { + if s.writeIfContextError(w, err) { + return + } + switch { + case errors.Is(err, store.ErrPersonNotFound): + writeError(w, http.StatusNotFound, "person_profile_not_found", "Person profile not found") + case errors.Is(err, store.ErrPersonRevisionConflict): + writeError(w, http.StatusConflict, "person_revision_conflict", "Person profile changed; reload and retry") + case errors.Is(err, store.ErrServiceAliasConflict): + writeError(w, http.StatusConflict, "service_alias_conflict", err.Error()) + case errors.Is(err, store.ErrPersonCategoryDuplicate): + writeError(w, http.StatusConflict, "person_category_duplicate", err.Error()) + case errors.Is(err, store.ErrPersonProfilePatchTooLarge): + writeError(w, http.StatusRequestEntityTooLarge, "profile_patch_too_large", err.Error()) + case isPersonProfileValidationError(err): + writeError(w, http.StatusBadRequest, "invalid_profile_value", err.Error()) + case errors.Is(err, store.ErrProfileValueNotFound): + writeError(w, http.StatusNotFound, "profile_value_not_found", err.Error()) + default: + s.logger.Error("structured person profile operation failed", "error", err) + writeError(w, http.StatusInternalServerError, "person_profile_failed", "Person profile operation failed") + } +} + +func isPersonProfileValidationError(err error) bool { + for _, target := range []error{ + store.ErrInvalidProvenance, store.ErrConfidenceScope, + store.ErrInvalidProfilePref, store.ErrInvalidProfileOrdinal, store.ErrInvalidPartialDate, + store.ErrInvalidPersonNameKind, store.ErrPersonNameValueMissing, + store.ErrInvalidContactAddressKind, store.ErrContactPointValueMissing, + store.ErrInvalidPersonAddressKind, store.ErrPersonAddressValueMissing, + store.ErrInvalidPersonDateKind, store.ErrPersonDateValueMissing, + store.ErrPersonCategoryEmpty, store.ErrInvalidPersonMediaKind, + store.ErrPersonMediaEmpty, store.ErrPersonMediaTooLarge, + store.ErrServiceNotFound, store.ErrServiceScopeRequired, + store.ErrServiceScopeForbidden, store.ErrServiceScopeIncomplete, + store.ErrNormalizationRejected, + store.ErrPersonProfilePatchEmpty, store.ErrProfileValueCloseBeforeActive, + } { + if errors.Is(err, target) { + return true + } + } + return false +} + +func writePersonStructuredProfile( + w http.ResponseWriter, status int, profile *store.PersonProfile, +) { + w.Header().Set("ETag", personETag(profile.Person)) + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, status, profile) +} + +// A patch may contain one 8 MiB inline media value. Base64 expansion plus +// surrounding JSON fits under 12 MiB without raising the shared person cap. +func decodeProfilePatchRequest( + w http.ResponseWriter, r *http.Request, +) (store.PersonProfilePatch, bool) { + var patch store.PersonProfilePatch + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, MaxPersonProfilePatchBytes)) + if err != nil { + var maxBytesError *http.MaxBytesError + if errors.As(err, &maxBytesError) { + writeError(w, http.StatusRequestEntityTooLarge, "profile_patch_too_large", + "Person profile patch is too large") + return patch, false + } + writeError(w, http.StatusBadRequest, "bad_request", "Invalid person profile patch") + return patch, false + } + var request PersonProfilePatchRequest + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid person profile patch: "+err.Error()) + return patch, false + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + writeError(w, http.StatusBadRequest, "bad_request", "Person profile patch must contain one JSON object") + return patch, false + } + // The request DTO is the OpenAPI allowlist. Transcoding that validated + // subset keeps runtime acceptance identical to the generated contract + // while the store retains its response-oriented envelope model. + encoded, err := json.Marshal(request) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid person profile patch") + return patch, false + } + if err := json.Unmarshal(encoded, &patch); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid person profile patch") + return patch, false + } + return patch, true +} diff --git a/internal/api/person_profile_values_test.go b/internal/api/person_profile_values_test.go new file mode 100644 index 000000000..90d1dbaad --- /dev/null +++ b/internal/api/person_profile_values_test.go @@ -0,0 +1,377 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/config" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil" +) + +func TestGetPersonProfileReturnsTypedValuesAndETag(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + server, st := newProfileTestServer(t) + personID := seedAPIPerson(t, st) + + recorder := doRequest(t, server, http.MethodGet, personProfilePath(personID), nil, nil) + require.Equal(http.StatusOK, recorder.Code, recorder.Body.String()) + assert.NotEmpty(recorder.Header().Get("ETag")) + assert.Equal("no-store", recorder.Header().Get("Cache-Control")) + + var profile store.PersonProfile + require.NoError(json.Unmarshal(recorder.Body.Bytes(), &profile), recorder.Body.String()) + require.Len(profile.ContactPoints, 1) + assert.Equal(store.ContactAddressEmail, profile.ContactPoints[0].AddressKind) + assert.Equal("alice@example.com", profile.ContactPoints[0].NormalizedValue) + assert.Equal("Alice@Example.com", profile.ContactPoints[0].OriginalValue) +} + +func TestPatchPersonProfileRoundTripsPartialDatesAndAddresses(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + server, st := newProfileTestServer(t) + personID := seedAPIPerson(t, st) + + read := doRequest(t, server, http.MethodGet, personProfilePath(personID), nil, nil) + require.Equal(http.StatusOK, read.Code, read.Body.String()) + etag := read.Header().Get("ETag") + + body := []byte(`{ + "dates": {"add": [{ + "date_kind": "birthday", + "date": {"month": 4, "day": 12}, + "original_value": "--0412", + "envelope": {"source": "user"} + }]}, + "addresses": {"add": [{ + "address_kind": "postal", + "street_address": "123 Example St.", + "locality": "Exampleville", + "postal_code": "90000", + "country_code": "US", + "geo_uri": "geo:37.386,-122.084", + "original_value": ";;123 Example St.;Exampleville;;90000;", + "envelope": {"source": "user", "pref": 1} + }]} + }`) + recorder := doRequest(t, server, http.MethodPatch, personProfilePath(personID), body, + map[string]string{"If-Match": etag}) + require.Equal(http.StatusOK, recorder.Code, recorder.Body.String()) + + var profile store.PersonProfile + require.NoError(json.Unmarshal(recorder.Body.Bytes(), &profile), recorder.Body.String()) + require.Len(profile.Dates, 1) + require.NotNil(profile.Dates[0].Date.Month) + assert.Equal(4, *profile.Dates[0].Date.Month) + assert.Nil(profile.Dates[0].Date.Year, "an absent year must stay absent, not become zero") + require.Len(profile.Addresses, 1) + assert.Equal("123 Example St.", *profile.Addresses[0].StreetAddress) + assert.Equal("geo:37.386,-122.084", *profile.Addresses[0].GeoURI) + assert.NotEqual(etag, recorder.Header().Get("ETag"), "the revision advanced") +} + +func TestPatchPersonProfileAcceptsFallbackBackedOptionalFields(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + server, st := newProfileTestServer(t) + personID := seedAPIPerson(t, st) + + read := doRequest(t, server, http.MethodGet, personProfilePath(personID), nil, nil) + require.Equal(http.StatusOK, read.Code, read.Body.String()) + body := []byte(`{ + "names":{"add":[{"name_kind":"formatted","formatted":"Alice Example","envelope":{"source":"user"}}]}, + "addresses":{"add":[{"address_kind":"postal","free_text":"Exampleville","envelope":{"source":"user"}}]}, + "dates":{"add":[{"date_kind":"custom","date_text":"Spring 2020","envelope":{"source":"user"}}]}, + "media":{"add":[{"media_kind":"photo","uri":"https://example.org/alice.jpg","envelope":{"source":"user"}}]} + }`) + response := doRequest(t, server, http.MethodPatch, personProfilePath(personID), body, + map[string]string{"If-Match": read.Header().Get("ETag")}) + require.Equal(http.StatusOK, response.Code, response.Body.String()) + + var profile store.PersonProfile + require.NoError(json.Unmarshal(response.Body.Bytes(), &profile)) + require.Len(profile.Names, 1) + assert.Equal("Alice Example", profile.Names[0].OriginalValue) + require.Len(profile.Addresses, 1) + assert.Equal("Exampleville", profile.Addresses[0].OriginalValue) + require.Len(profile.Dates, 1) + assert.Equal("Spring 2020", profile.Dates[0].OriginalValue) + require.Len(profile.Media, 1) + assert.Equal("https://example.org/alice.jpg", profile.Media[0].OriginalValue) +} + +func TestPatchPersonProfileRequiresIfMatchAndRejectsStaleRevision(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + server, st := newProfileTestServer(t) + personID := seedAPIPerson(t, st) + + body := []byte(`{"categories":{"add":[{"original_value":"Friends","envelope":{"source":"user"}}]}}`) + missing := doRequest(t, server, http.MethodPatch, personProfilePath(personID), body, nil) + assert.Equal(http.StatusPreconditionRequired, missing.Code, missing.Body.String()) + + read := doRequest(t, server, http.MethodGet, personProfilePath(personID), nil, nil) + require.Equal(http.StatusOK, read.Code, read.Body.String()) + etag := read.Header().Get("ETag") + first := doRequest(t, server, http.MethodPatch, personProfilePath(personID), body, + map[string]string{"If-Match": etag}) + require.Equal(http.StatusOK, first.Code, first.Body.String()) + + stale := doRequest(t, server, http.MethodPatch, personProfilePath(personID), + []byte(`{"categories":{"add":[{"original_value":"Book Club","envelope":{"source":"user"}}]}}`), + map[string]string{"If-Match": etag}) + assert.Equal(http.StatusConflict, stale.Code, stale.Body.String()) +} + +func TestPatchPersonProfileMapsValidationErrorsToBadRequest(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + server, st := newProfileTestServer(t) + personID := seedAPIPerson(t, st) + read := doRequest(t, server, http.MethodGet, personProfilePath(personID), nil, nil) + require.Equal(http.StatusOK, read.Code, read.Body.String()) + etag := read.Header().Get("ETag") + + cases := []struct { + name string + body string + }{ + {"unknown provenance", `{"categories":{"add":[{"original_value":"Friends","envelope":{"source":"beeper"}}]}}`}, + {"scope required", `{"contact_points":{"add":[{"address_kind":"username","service_slug":"slack","original_value":"alice","envelope":{"source":"user"}}]}}`}, + {"unknown service", `{"contact_points":{"add":[{"address_kind":"username","service_slug":"no-such","original_value":"alice","envelope":{"source":"user"}}]}}`}, + {"invalid partial date", `{"dates":{"add":[{"date_kind":"birthday","date":{"year":1985,"month":2,"day":30},"original_value":"1985-02-30","envelope":{"source":"user"}}]}}`}, + {"close before active", `{"categories":{"add":[{"original_value":"Friends","envelope":{"source":"user","active_from":"2026-08-08T12:00:00Z","active_until":"2026-08-08T11:00:00Z"}}]}}`}, + {"confidence on declared value", `{"categories":{"add":[{"original_value":"Friends","envelope":{"source":"user","confidence":0.5}}]}}`}, + {"negative ordinal", `{"categories":{"add":[{"original_value":"Friends","envelope":{"source":"user","ordinal":-1}}]}}`}, + {"empty patch", `{}`}, + } + for _, tc := range cases { + recorder := doRequest(t, server, http.MethodPatch, personProfilePath(personID), + []byte(tc.body), map[string]string{"If-Match": etag}) + assert.Equal(http.StatusBadRequest, recorder.Code, "%s: %s", tc.name, recorder.Body.String()) + } +} + +func TestPatchPersonProfilePreservesExplicitZeroOrdinal(t *testing.T) { + require := require.New(t) + server, st := newProfileTestServer(t) + personID := seedAPIPerson(t, st) + read := doRequest(t, server, http.MethodGet, personProfilePath(personID), nil, nil) + require.Equal(http.StatusOK, read.Code, read.Body.String()) + + response := doRequest(t, server, http.MethodPatch, personProfilePath(personID), + []byte(`{"contact_points":{"add":[{"address_kind":"email","original_value":"pinned@example.com","envelope":{"source":"user","ordinal":0}}]}}`), + map[string]string{"If-Match": read.Header().Get("ETag")}) + require.Equal(http.StatusOK, response.Code, response.Body.String()) + var profile store.PersonProfile + require.NoError(json.Unmarshal(response.Body.Bytes(), &profile)) + for _, point := range profile.ContactPoints { + if point.OriginalValue == "pinned@example.com" { + assert.Equal(t, 0, point.Envelope.Ordinal) + return + } + } + require.Fail("patched contact point was not returned") +} + +func TestPatchPersonProfileRejectsResponseOnlyEnvelopeFields(t *testing.T) { + responseOnlyFields := []struct { + name string + value string + }{ + {name: "id", value: `123`}, + {name: "created_at", value: `"2026-08-08T12:00:00Z"`}, + {name: "updated_at", value: `"2026-08-08T12:00:00Z"`}, + {name: "superseded_at", value: `"2026-08-08T12:00:00Z"`}, + } + for _, field := range responseOnlyFields { + t.Run(field.name, func(t *testing.T) { + server, st := newProfileTestServer(t) + personID := seedAPIPerson(t, st) + read := doRequest(t, server, http.MethodGet, personProfilePath(personID), nil, nil) + require.Equal(t, http.StatusOK, read.Code, read.Body.String()) + + body := fmt.Sprintf( + `{"categories":{"add":[{"original_value":"Friends","envelope":{"source":"user",%q:%s}}]}}`, + field.name, field.value, + ) + response := doRequest(t, server, http.MethodPatch, personProfilePath(personID), + []byte(body), map[string]string{"If-Match": read.Header().Get("ETag")}) + assert.Equal(t, http.StatusBadRequest, response.Code, response.Body.String()) + }) + } +} + +func TestPatchPersonProfileRejectsOversizedBody(t *testing.T) { + server, st := newProfileTestServer(t) + personID := seedAPIPerson(t, st) + read := doRequest(t, server, http.MethodGet, personProfilePath(personID), nil, nil) + require.Equal(t, http.StatusOK, read.Code, read.Body.String()) + + response := doRequest(t, server, http.MethodPatch, personProfilePath(personID), + bytes.Repeat([]byte(" "), MaxPersonProfilePatchBytes+1), + map[string]string{"If-Match": read.Header().Get("ETag")}) + assert.Equal(t, http.StatusRequestEntityTooLarge, response.Code, response.Body.String()) +} + +func TestGetPersonProfileHistoryIsASeparateEndpoint(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + server, st := newProfileTestServer(t) + personID := seedAPIPerson(t, st) + + recorder := doRequest(t, server, http.MethodGet, personProfilePath(personID)+"/history", nil, nil) + require.Equal(http.StatusOK, recorder.Code, recorder.Body.String()) + var history store.PersonProfileHistory + require.NoError(json.Unmarshal(recorder.Body.Bytes(), &history), recorder.Body.String()) + assert.Equal(personID, history.Person.ID) + assert.NotNil(history.Observations, "the observations field is always present, even when empty") +} + +func TestProfileEndpointsRejectUnknownPersonAndBadID(t *testing.T) { + assert := assert.New(t) + server, _ := newProfileTestServer(t) + missing := doRequest(t, server, http.MethodGet, "/api/v1/persons/999999/profile", nil, nil) + assert.Equal(http.StatusNotFound, missing.Code, missing.Body.String()) + bad := doRequest(t, server, http.MethodGet, "/api/v1/persons/0/profile", nil, nil) + assert.Equal(http.StatusBadRequest, bad.Code, bad.Body.String()) +} + +func TestGetPersonProfileMediaContentReturnsStoredBytes(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + server, st := newProfileTestServer(t) + personID := seedAPIPerson(t, st) + payload := []byte("synthetic-profile-photo") + media, err := st.AddPersonMediaContext(t.Context(), personID, store.PersonMediaInput{ + MediaKind: store.PersonMediaPhoto, + MediaType: new("image/png"), + Data: payload, + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(err) + + response := doRequest(t, server, http.MethodGet, + personProfileMediaContentPath(personID, media.Envelope.ID), nil, nil, + ) + require.Equal(http.StatusOK, response.Code, response.Body.String()) + assert.Equal(payload, response.Body.Bytes()) + assert.Equal("image/png", response.Header().Get("Content-Type")) + assert.Equal(strconv.Itoa(len(payload)), response.Header().Get("Content-Length")) + assert.Equal("attachment", response.Header().Get("Content-Disposition")) + assert.Equal("nosniff", response.Header().Get("X-Content-Type-Options")) + assert.Equal("no-store", response.Header().Get("Cache-Control")) +} + +func TestGetPersonProfileMediaContentRejectsMissingAndURIOnlyValues(t *testing.T) { + assert := assert.New(t) + server, st := newProfileTestServer(t) + personID := seedAPIPerson(t, st) + media, err := st.AddPersonMediaContext(t.Context(), personID, store.PersonMediaInput{ + MediaKind: store.PersonMediaPhoto, + URI: new("https://example.com/alice.png"), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(t, err) + + for _, path := range []string{ + personProfileMediaContentPath(personID, media.Envelope.ID), + personProfileMediaContentPath(personID, media.Envelope.ID+1), + personProfileMediaContentPath(personID+1, media.Envelope.ID), + fmt.Sprintf("/api/v1/persons/%d/profile/media/0/content", personID), + } { + response := doRequest(t, server, http.MethodGet, path, nil, nil) + if path == fmt.Sprintf("/api/v1/persons/%d/profile/media/0/content", personID) { + assert.Equal(http.StatusBadRequest, response.Code, response.Body.String()) + } else { + assert.Equal(http.StatusNotFound, response.Code, response.Body.String()) + } + } +} + +func TestGetPersonProfileMediaContentRequiresAuthentication(t *testing.T) { + const apiKey = "profile-media-test-key" + st := testutil.NewTestStore(t) + wrapped := &stubIdentityCacheStore{Store: st} + server := NewServer(&config.Config{Server: config.ServerConfig{ + APIPort: 8080, + APIKey: apiKey, + }}, wrapped, nil, testLogger()).Router() + personID := seedAPIPerson(t, st) + media, err := st.AddPersonMediaContext(t.Context(), personID, store.PersonMediaInput{ + MediaKind: store.PersonMediaPhoto, + Data: []byte("authenticated-profile-photo"), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(t, err) + path := personProfileMediaContentPath(personID, media.Envelope.ID) + + unauthorized := doRequest(t, server, http.MethodGet, path, nil, nil) + assert.Equal(t, http.StatusUnauthorized, unauthorized.Code, unauthorized.Body.String()) + authorized := doRequest(t, server, http.MethodGet, path, nil, + map[string]string{"X-Api-Key": apiKey}, + ) + assert.Equal(t, http.StatusOK, authorized.Code, authorized.Body.String()) +} + +func newProfileTestServer(t *testing.T) (http.Handler, *store.Store) { + t.Helper() + server, wrapped := newIdentityLinkTestServer(t) + return server.Router(), wrapped.Store +} + +func seedAPIPerson(t *testing.T, st *store.Store) int64 { + t.Helper() + participantID, err := st.EnsureParticipantByIdentifier( + "email", "alice@example.com", "Alice Example", + ) + require.NoError(t, err) + person, _, err := st.CreatePersonFromParticipant(participantID) + require.NoError(t, err) + _, err = st.AddPersonContactPointContext(t.Context(), person.ID, store.PersonContactPointInput{ + AddressKind: store.ContactAddressEmail, + OriginalValue: "Alice@Example.com", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(t, err) + return person.ID +} + +func personProfilePath(personID int64) string { + return fmt.Sprintf("/api/v1/persons/%d/profile", personID) +} + +func personProfileMediaContentPath(personID, mediaID int64) string { + return fmt.Sprintf( + "/api/v1/persons/%d/profile/media/%d/content", personID, mediaID, + ) +} + +func doRequest( + t *testing.T, + handler http.Handler, + method, path string, + body []byte, + headers map[string]string, +) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest(method, path, bytes.NewReader(body)) + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + for name, value := range headers { + request.Header.Set(name, value) + } + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + return response +} diff --git a/internal/api/person_profiles.go b/internal/api/person_profiles.go index aeba08287..72cd185be 100644 --- a/internal/api/person_profiles.go +++ b/internal/api/person_profiles.go @@ -249,7 +249,7 @@ func addPersonIDParameter(operation *huma.Operation) { func addPersonIfMatchParameter(operation *huma.Operation) { operation.Parameters = append(operation.Parameters, &huma.Param{ - Name: "If-Match", In: "header", Required: true, + Name: ifMatchHeaderName, In: "header", Required: true, Description: "Strong ETag returned by the latest person profile read. " + "Must be the exact single tag from that read; the RFC 7232 forms `*` " + "and comma-separated tag lists are not supported.", @@ -280,7 +280,7 @@ func personETag(person store.Person) string { } func personIfMatch(w http.ResponseWriter, r *http.Request, id int64) (int64, bool) { - values := r.Header.Values("If-Match") + values := r.Header.Values(ifMatchHeaderName) if len(values) == 0 || (len(values) == 1 && strings.TrimSpace(values[0]) == "") { writeError(w, http.StatusPreconditionRequired, "if_match_required", "If-Match is required") return 0, false diff --git a/internal/api/routes.go b/internal/api/routes.go index 90708f6af..92531334c 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -222,6 +222,8 @@ func (s *Server) registerHumaRoutes(api huma.API, apiV1 huma.API) { s.registerExploreRoutes(apiV1) s.registerFilesRoutes(apiV1) s.registerPersonProfileRoutes(apiV1) + s.registerPersonProfileValueRoutes(apiV1) + s.registerCommunicationServiceRoutes(apiV1) s.registerAttributeDefinitionRoutes(apiV1) s.registerPersonAttributeRoutes(apiV1) s.registerPeopleRoutes(apiV1) diff --git a/internal/api/saved_views.go b/internal/api/saved_views.go index dd9485e1b..ec6972052 100644 --- a/internal/api/saved_views.go +++ b/internal/api/saved_views.go @@ -105,7 +105,7 @@ func addSavedViewIDParameter(operation *huma.Operation) { func addSavedViewIfMatchParameter(operation *huma.Operation) { operation.Parameters = append(operation.Parameters, &huma.Param{ - Name: "If-Match", In: "header", Required: true, + Name: ifMatchHeaderName, In: "header", Required: true, Description: "Strong ETag returned by the latest Saved View read", Schema: &huma.Schema{Type: huma.TypeString}, }) @@ -375,7 +375,7 @@ func savedViewETag(view store.SavedView) string { } func savedViewIfMatch(w http.ResponseWriter, r *http.Request, id int64) (int64, bool) { - values := r.Header.Values("If-Match") + values := r.Header.Values(ifMatchHeaderName) if len(values) == 0 || (len(values) == 1 && strings.TrimSpace(values[0]) == "") { writeError(w, http.StatusPreconditionRequired, "if_match_required", "If-Match is required") return 0, false diff --git a/internal/api/settings.go b/internal/api/settings.go index d27d5cbcb..47e6fb016 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -129,7 +129,7 @@ func (s *Server) registerSettingsRoutes(api huma.API) { patch := rawAPIV1Operation("patchSettings", http.MethodPatch, "/settings", "Update browser-managed settings") patch.Parameters = append(patch.Parameters, &huma.Param{ - Name: "If-Match", + Name: ifMatchHeaderName, In: "header", Description: "Strong ETag returned by the latest settings read", Required: true, @@ -215,7 +215,7 @@ func (s *Server) handleGetSettings(w http.ResponseWriter, _ *http.Request) { } func (s *Server) handlePatchSettings(w http.ResponseWriter, r *http.Request) { - ifMatches := r.Header.Values("If-Match") + ifMatches := r.Header.Values(ifMatchHeaderName) if len(ifMatches) != 1 || strings.TrimSpace(ifMatches[0]) == "" { writeError(w, http.StatusPreconditionRequired, "if_match_required", "If-Match is required") return diff --git a/internal/identityindex/fingerprint.go b/internal/identityindex/fingerprint.go index 8f7210196..365e1da85 100644 --- a/internal/identityindex/fingerprint.go +++ b/internal/identityindex/fingerprint.go @@ -72,3 +72,36 @@ func FingerprintConversationTypes(rows ConversationParticipantRows) (string, err } return fmt.Sprintf("sha256:%x", hash.Sum(nil)), nil } + +// FingerprintConversationMetadata hashes ordered +// (conversation_id, type, title) rows. Length-prefixing both strings keeps the +// variable-width values unambiguous, including when either contains a value +// that could otherwise act as a separator. +func FingerprintConversationMetadata(rows ConversationParticipantRows) (string, error) { + hash := sha256.New() + var encoded [16]byte + for rows.Next() { + var conversationID int64 + var conversationType, title string + if err := rows.Scan(&conversationID, &conversationType, &title); err != nil { + return "", fmt.Errorf("scan conversation metadata fingerprint: %w", err) + } + if conversationID < 0 { + return "", fmt.Errorf( + "fingerprint conversation metadata: negative ID %d", + conversationID, + ) + } + binary.BigEndian.PutUint64(encoded[:8], uint64(conversationID)) + binary.BigEndian.PutUint64(encoded[8:], uint64(len(conversationType))) + _, _ = hash.Write(encoded[:]) + _, _ = hash.Write([]byte(conversationType)) + binary.BigEndian.PutUint64(encoded[:8], uint64(len(title))) + _, _ = hash.Write(encoded[:8]) + _, _ = hash.Write([]byte(title)) + } + if err := rows.Err(); err != nil { + return "", fmt.Errorf("iterate conversation metadata fingerprint: %w", err) + } + return fmt.Sprintf("sha256:%x", hash.Sum(nil)), nil +} diff --git a/internal/query/cache_state.go b/internal/query/cache_state.go index e27bc7bcd..6ba0f13c0 100644 --- a/internal/query/cache_state.go +++ b/internal/query/cache_state.go @@ -18,10 +18,11 @@ import ( // cache publisher and analytical readers. Version 15 adds the compact // relationship activity, people, domain, and daily read model; version 16 // adds has_attachments to the relationship activity dataset; version 17 adds -// the envelope address snapshot (email_address) to message_recipients. The -// bump forces a full rebuild so committed caches never mix recipient shards -// with and without the column. -const CacheSchemaVersion = 17 +// the envelope address snapshot (email_address) to message_recipients; version +// 18 adds participant-directory revision tracking so a pre-upgrade cache cannot +// be mistaken for one that has observed later participant metadata changes. +// Schema bumps force a full rebuild before readers use an older publication. +const CacheSchemaVersion = 18 // CacheSyncState is the commit marker written after a complete analytics // cache publication. SQLite remains authoritative; these watermarks only @@ -45,23 +46,26 @@ type CacheSyncState struct { // so this field must only advance on a full rebuild — see // cacheops.RefreshIdentityDatasets. AccountIdentityRevision int64 `json:"account_identity_revision,omitempty"` - // ParticipantIdentifierRevision tracks identifier-mapping changes - // (SetParticipantIdentifier creating or repointing rows). Identifiers - // bake into the identity directory datasets (participant_identifiers, - // relationship_people search values) but not into per-row activity - // facts, so drift here alone is repaired by the derived-dataset - // refresh and never forces a full rebuild. - ParticipantIdentifierRevision int64 `json:"participant_identifier_revision,omitempty"` - PublishedAt time.Time `json:"published_at"` - DatasetFingerprint string `json:"dataset_fingerprint"` + // ParticipantIdentifierRevision tracks identifier row and classification + // changes. Identifiers bake into the identity directory datasets + // (participant_identifiers, relationship_people search values) but not + // into per-row activity facts, so drift here alone is repaired by the + // derived-dataset refresh and never forces a full rebuild. + ParticipantIdentifierRevision int64 `json:"participant_identifier_revision,omitempty"` + // ParticipantDisplayNameRevision tracks participant display-name changes. + // Display names bake into participants.parquet and the relationship_people + // labels/search values, but not into message facts, so drift here is + // repaired by the derived-dataset refresh without rewriting message + // shards. + ParticipantDisplayNameRevision int64 `json:"participant_display_name_revision,omitempty"` + PublishedAt time.Time `json:"published_at"` + DatasetFingerprint string `json:"dataset_fingerprint"` ConversationParticipantsFingerprint string `json:"conversation_participants_fingerprint,omitempty"` - // ConversationTypesFingerprint hashes (id, conversation_type) for every - // conversation inside the committed message watermark. conversation_type - // is mutable (EnsureConversationWithType upserts it) and is baked into - // committed relationship_activity rows, which incremental builds and - // index-only refreshes otherwise never revisit — this fingerprint is how - // that drift is detected. + // ConversationTypesFingerprint hashes (id, conversation_type, title) for + // every conversation inside the committed message watermark. Both metadata + // fields are mutable and bake into cache datasets that incremental builds + // otherwise never revisit; this fingerprint detects that drift. ConversationTypesFingerprint string `json:"conversation_types_fingerprint,omitempty"` Stats identityindex.CacheStatsSummary `json:"stats"` } @@ -96,7 +100,7 @@ func (e *CacheUnavailableError) Unwrap() error { return ErrCacheUnavailable } // Revision identifies one committed cache publication. It intentionally uses // only commit-marker fields, never ambient filesystem state. func (s CacheSyncState) Revision() string { - payload := fmt.Sprintf("v=%d|message=%d|watermark=%s|run=%d|add=%d|update=%d|fail_count=%d|fail_sum=%d|identity=%d|account_identity=%d|participant_identifier=%d|published=%s", + payload := fmt.Sprintf("v=%d|message=%d|watermark=%s|run=%d|add=%d|update=%d|fail_count=%d|fail_sum=%d|identity=%d|account_identity=%d|participant_identifier=%d|participant_display_name=%d|published=%s", s.SchemaVersion, s.LastMessageID, s.LastSyncAt.UTC().Format(time.RFC3339Nano), @@ -108,6 +112,7 @@ func (s CacheSyncState) Revision() string { s.IdentityRevision, s.AccountIdentityRevision, s.ParticipantIdentifierRevision, + s.ParticipantDisplayNameRevision, s.PublishedAt.UTC().Format(time.RFC3339Nano), ) return fmt.Sprintf("cache-%x", sha256.Sum256([]byte(payload))) diff --git a/internal/query/cache_state_test.go b/internal/query/cache_state_test.go index ab5d38edd..76f88b49b 100644 --- a/internal/query/cache_state_test.go +++ b/internal/query/cache_state_test.go @@ -190,15 +190,17 @@ func TestInspectCacheReadinessPrefersStaleSchemaWhenNewDatasetIsMissing(t *testi func TestCacheRevisionUsesOnlyCommittedStateWatermarks(t *testing.T) { assert := assert.New(t) state := CacheSyncState{ - SchemaVersion: CacheSchemaVersion, - LastMessageID: 41, - LastCompletedSyncRunID: 5, - LastCacheAdditionCount: 37, - LastCacheUpdateCount: 3, - LastFailedSyncRunCount: 2, - LastFailedSyncRunIDSum: 19, - IdentityRevision: 7, - PublishedAt: time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC), + SchemaVersion: CacheSchemaVersion, + LastMessageID: 41, + LastCompletedSyncRunID: 5, + LastCacheAdditionCount: 37, + LastCacheUpdateCount: 3, + LastFailedSyncRunCount: 2, + LastFailedSyncRunIDSum: 19, + IdentityRevision: 7, + ParticipantIdentifierRevision: 11, + ParticipantDisplayNameRevision: 13, + PublishedAt: time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC), } revision := state.Revision() require.NotEmpty(t, revision) @@ -213,6 +215,12 @@ func TestCacheRevisionUsesOnlyCommittedStateWatermarks(t *testing.T) { changed.IdentityRevision++ assert.NotEqual(revision, changed.Revision()) changed = state + changed.ParticipantIdentifierRevision++ + assert.NotEqual(revision, changed.Revision()) + changed = state + changed.ParticipantDisplayNameRevision++ + assert.NotEqual(revision, changed.Revision()) + changed = state changed.PublishedAt = changed.PublishedAt.Add(time.Second) assert.NotEqual(revision, changed.Revision()) changed = state diff --git a/internal/store/communication_services.go b/internal/store/communication_services.go new file mode 100644 index 000000000..32d21b184 --- /dev/null +++ b/internal/store/communication_services.go @@ -0,0 +1,648 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "regexp" + "sort" + "strings" + "time" + + "go.kenn.io/msgvault/internal/textimport" +) + +const communicationServicesSeedV1 = "communication_services_seed_v1" + +const ( + ScopePolicyNone = "none" + ScopePolicyOptional = "optional" + ScopePolicyRequired = "required" +) + +const ( + NormalizationNone = "none" + NormalizationLower = "lower" + NormalizationEmail = "email" + NormalizationPhoneE164 = "phone_e164" + NormalizationStripAtLower = "strip_at_lower" + NormalizationByAddressKind = "by_address_kind" +) + +var ( + ErrServiceNotFound = errors.New("communication service not found") + ErrServiceSlugConflict = errors.New("communication service slug already exists") + ErrServiceAliasConflict = errors.New("communication service alias already maps to another service") + ErrServiceNormalizationImmutable = errors.New("communication service normalization settings are immutable") + ErrInvalidServiceSlug = errors.New("communication service slug must match [a-z0-9][a-z0-9-]*") + ErrInvalidScopePolicy = errors.New("invalid communication service scope policy") + ErrInvalidNormalization = errors.New("invalid communication service normalization strategy") + ErrServiceScopeRequired = errors.New("communication service requires a scope value") + ErrServiceScopeForbidden = errors.New("communication service does not accept a scope value") + ErrServiceScopeIncomplete = errors.New("communication service scope requires both scope kind and scope value") + ErrNormalizationRejected = errors.New("value cannot be normalized for this service") +) + +type CommunicationService struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + DisplayLabel string `json:"display_label"` + Aliases []string `json:"aliases"` + ScopePolicy string `json:"scope_policy"` + DefaultScopeKind *string `json:"default_scope_kind,omitempty"` + Normalization string `json:"normalization"` + NormalizationVersion int `json:"normalization_version"` + URIScheme *string `json:"uri_scheme,omitempty"` + ProfileURLTemplate *string `json:"profile_url_template,omitempty"` + IsSystem bool `json:"is_system"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CommunicationServiceInput struct { + Slug string + DisplayLabel string + Aliases []string + ScopePolicy string + DefaultScopeKind *string + Normalization string + NormalizationVersion int + URIScheme *string + ProfileURLTemplate *string +} + +type ContactAddressKind string + +const ( + ContactAddressEmail ContactAddressKind = "email" + ContactAddressPhone ContactAddressKind = "phone" + ContactAddressUsername ContactAddressKind = "username" + ContactAddressIMPP ContactAddressKind = "impp" + ContactAddressURL ContactAddressKind = "url" + ContactAddressSocial ContactAddressKind = "social" + ContactAddressCalendar ContactAddressKind = "calendar" + ContactAddressContactURI ContactAddressKind = "contact_uri" + ContactAddressOrgDirectory ContactAddressKind = "org_directory" + ContactAddressLanguage ContactAddressKind = "language" +) + +func (k ContactAddressKind) Valid() bool { + switch k { + case ContactAddressEmail, ContactAddressPhone, ContactAddressUsername, + ContactAddressIMPP, ContactAddressURL, ContactAddressSocial, + ContactAddressCalendar, ContactAddressContactURI, + ContactAddressOrgDirectory, ContactAddressLanguage: + return true + default: + return false + } +} + +var serviceSlugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + +var seededCommunicationServices = []CommunicationServiceInput{ + {Slug: "whatsapp", DisplayLabel: "WhatsApp", ScopePolicy: ScopePolicyNone, Normalization: NormalizationPhoneE164, NormalizationVersion: 1}, + {Slug: "telegram", DisplayLabel: "Telegram", ScopePolicy: ScopePolicyNone, Normalization: NormalizationStripAtLower, NormalizationVersion: 1, URIScheme: new("tg"), ProfileURLTemplate: new("https://t.me/{username}")}, + {Slug: "facebook", DisplayLabel: "Facebook", ScopePolicy: ScopePolicyNone, Normalization: NormalizationStripAtLower, NormalizationVersion: 1, ProfileURLTemplate: new("https://www.facebook.com/{username}")}, + {Slug: "messenger", DisplayLabel: "Messenger", ScopePolicy: ScopePolicyNone, Normalization: NormalizationStripAtLower, NormalizationVersion: 1, ProfileURLTemplate: new("https://m.me/{username}")}, + {Slug: "instagram", DisplayLabel: "Instagram", ScopePolicy: ScopePolicyNone, Normalization: NormalizationStripAtLower, NormalizationVersion: 1, ProfileURLTemplate: new("https://www.instagram.com/{username}")}, + {Slug: "signal", DisplayLabel: "Signal", ScopePolicy: ScopePolicyNone, Normalization: NormalizationPhoneE164, NormalizationVersion: 1}, + {Slug: "x", DisplayLabel: "X", Aliases: []string{"twitter"}, ScopePolicy: ScopePolicyNone, Normalization: NormalizationStripAtLower, NormalizationVersion: 1, ProfileURLTemplate: new("https://x.com/{username}")}, + {Slug: "discord", DisplayLabel: "Discord", ScopePolicy: ScopePolicyNone, Normalization: NormalizationLower, NormalizationVersion: 1}, + {Slug: "slack", DisplayLabel: "Slack", ScopePolicy: ScopePolicyRequired, DefaultScopeKind: new("workspace"), Normalization: NormalizationLower, NormalizationVersion: 1}, + {Slug: "linkedin", DisplayLabel: "LinkedIn", ScopePolicy: ScopePolicyNone, Normalization: NormalizationLower, NormalizationVersion: 1, ProfileURLTemplate: new("https://www.linkedin.com/in/{username}")}, + {Slug: "sms", DisplayLabel: "SMS", ScopePolicy: ScopePolicyNone, Normalization: NormalizationPhoneE164, NormalizationVersion: 1, URIScheme: new("sms")}, + {Slug: "rcs", DisplayLabel: "RCS", ScopePolicy: ScopePolicyNone, Normalization: NormalizationPhoneE164, NormalizationVersion: 1, URIScheme: new("sms")}, + {Slug: "google-messages", DisplayLabel: "Google Messages", Aliases: []string{"gmessages"}, ScopePolicy: ScopePolicyNone, Normalization: NormalizationPhoneE164, NormalizationVersion: 1, URIScheme: new("sms")}, + {Slug: "google-voice", DisplayLabel: "Google Voice", ScopePolicy: ScopePolicyNone, Normalization: NormalizationPhoneE164, NormalizationVersion: 1, URIScheme: new("tel")}, + {Slug: "google-chat", DisplayLabel: "Google Chat", ScopePolicy: ScopePolicyOptional, DefaultScopeKind: new("account"), Normalization: NormalizationEmail, NormalizationVersion: 1}, + {Slug: "irc", DisplayLabel: "IRC", ScopePolicy: ScopePolicyRequired, DefaultScopeKind: new("network"), Normalization: NormalizationLower, NormalizationVersion: 1, URIScheme: new("irc")}, + {Slug: "groupme", DisplayLabel: "GroupMe", ScopePolicy: ScopePolicyNone, Normalization: NormalizationPhoneE164, NormalizationVersion: 1}, + {Slug: "imessage", DisplayLabel: "iMessage", ScopePolicy: ScopePolicyNone, Normalization: NormalizationByAddressKind, NormalizationVersion: 1}, + {Slug: "line", DisplayLabel: "LINE", ScopePolicy: ScopePolicyNone, Normalization: NormalizationLower, NormalizationVersion: 1}, + {Slug: "bluesky", DisplayLabel: "Bluesky", Aliases: []string{"bsky"}, ScopePolicy: ScopePolicyNone, Normalization: NormalizationStripAtLower, NormalizationVersion: 1, ProfileURLTemplate: new("https://bsky.app/profile/{username}")}, + // Matrix identifiers remain case-sensitive; lowercasing could merge two + // distinct archived participants. + {Slug: "matrix", DisplayLabel: "Matrix", ScopePolicy: ScopePolicyRequired, DefaultScopeKind: new("server"), Normalization: NormalizationNone, NormalizationVersion: 1, URIScheme: new("matrix")}, + {Slug: "reddit", DisplayLabel: "Reddit", ScopePolicy: ScopePolicyNone, Normalization: NormalizationStripAtLower, NormalizationVersion: 1, ProfileURLTemplate: new("https://www.reddit.com/user/{username}")}, + {Slug: "kakaotalk", DisplayLabel: "KakaoTalk", ScopePolicy: ScopePolicyNone, Normalization: NormalizationLower, NormalizationVersion: 1}, + {Slug: "wechat", DisplayLabel: "WeChat", ScopePolicy: ScopePolicyNone, Normalization: NormalizationLower, NormalizationVersion: 1}, +} + +func (s *Store) ListCommunicationServicesContext(ctx context.Context, includeInactive bool) ([]CommunicationService, error) { + query := `SELECT id, slug, display_label, scope_policy, default_scope_kind, + normalization, normalization_version, uri_scheme, profile_url_template, + is_system, is_active, created_at, updated_at + FROM communication_services` + if !includeInactive { + query += ` WHERE is_active = TRUE` + } + query += ` ORDER BY slug` + rows, err := s.db.QueryContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("list communication services: %w", err) + } + defer func() { _ = rows.Close() }() + + services := make([]CommunicationService, 0) + for rows.Next() { + service, err := scanCommunicationService(rows) + if err != nil { + return nil, fmt.Errorf("scan communication service: %w", err) + } + services = append(services, *service) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list communication services: %w", err) + } + aliases, err := s.loadAllServiceAliasesContext(ctx) + if err != nil { + return nil, err + } + for i := range services { + services[i].Aliases = aliases[services[i].ID] + } + return services, nil +} + +func (s *Store) GetCommunicationServiceContext(ctx context.Context, id int64) (*CommunicationService, error) { + service, err := scanCommunicationService(s.db.QueryRowContext(ctx, serviceSelect+` WHERE id = ?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrServiceNotFound + } + if err != nil { + return nil, fmt.Errorf("get communication service: %w", err) + } + service.Aliases, err = s.loadServiceAliasesContext(ctx, id) + return service, err +} + +func (s *Store) ResolveCommunicationServiceContext(ctx context.Context, slugOrAlias string) (*CommunicationService, error) { + lookup := strings.ToLower(strings.TrimSpace(slugOrAlias)) + service, err := scanCommunicationService(s.db.QueryRowContext(ctx, serviceSelect+` + WHERE slug = ? OR id = ( + SELECT service_id FROM communication_service_aliases WHERE alias = ? + ) + ORDER BY CASE WHEN slug = ? THEN 0 ELSE 1 END + LIMIT 1`, lookup, lookup, lookup)) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrServiceNotFound + } + if err != nil { + return nil, fmt.Errorf("resolve communication service: %w", err) + } + service.Aliases, err = s.loadServiceAliasesContext(ctx, service.ID) + return service, err +} + +func (s *Store) EnsureCommunicationServiceContext(ctx context.Context, input CommunicationServiceInput) (*CommunicationService, bool, error) { + if err := validateCommunicationServiceInput(input); err != nil { + return nil, false, err + } + var service *CommunicationService + created := false + err := s.withTxContext(ctx, func(tx *loggedTx) error { + if err := s.lockCommunicationServiceNamesTx(ctx, tx, input); err != nil { + return err + } + var err error + service, err = getCommunicationServiceBySlugTx(ctx, tx, input.Slug) + if err == nil { + return nil + } + if !errors.Is(err, ErrServiceNotFound) { + return err + } + names := make([]string, 0, len(input.Aliases)+1) + names = append(names, input.Slug) + names = append(names, input.Aliases...) + if err := ensureAliasesAvailableTx(ctx, tx, 0, names); err != nil { + return err + } + var id int64 + if err := tx.QueryRowContext(ctx, `INSERT INTO communication_services ( + slug, display_label, scope_policy, default_scope_kind, normalization, + normalization_version, uri_scheme, profile_url_template, is_system + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, FALSE) RETURNING id`, + input.Slug, input.DisplayLabel, input.ScopePolicy, stringValue(input.DefaultScopeKind), + input.Normalization, input.NormalizationVersion, stringValue(input.URIScheme), + stringValue(input.ProfileURLTemplate), + ).Scan(&id); err != nil { + return fmt.Errorf("insert communication service: %w", err) + } + if err := s.replaceServiceAliasesTx(ctx, tx, id, input.Aliases); err != nil { + return err + } + service, err = getCommunicationServiceTx(ctx, tx, id) + created = err == nil + return err + }) + return service, created, err +} + +func (s *Store) UpdateCommunicationServiceContext(ctx context.Context, id int64, input CommunicationServiceInput) (*CommunicationService, error) { + if err := validateCommunicationServiceInput(input); err != nil { + return nil, err + } + var service *CommunicationService + err := s.withTxContext(ctx, func(tx *loggedTx) error { + if err := s.lockCommunicationServiceNamesTx(ctx, tx, input); err != nil { + return err + } + existing, err := getCommunicationServiceTx(ctx, tx, id) + if err != nil { + return err + } + if existing.Slug != input.Slug { + return ErrServiceSlugConflict + } + if existing.Normalization != input.Normalization || + existing.NormalizationVersion != input.NormalizationVersion { + return ErrServiceNormalizationImmutable + } + if err := ensureAliasesAvailableTx(ctx, tx, id, input.Aliases); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `UPDATE communication_services SET + display_label = ?, scope_policy = ?, default_scope_kind = ?, + normalization = ?, normalization_version = ?, uri_scheme = ?, + profile_url_template = ?, updated_at = `+s.dialect.Now()+` + WHERE id = ?`, + input.DisplayLabel, input.ScopePolicy, stringValue(input.DefaultScopeKind), + input.Normalization, input.NormalizationVersion, stringValue(input.URIScheme), + stringValue(input.ProfileURLTemplate), id, + ); err != nil { + return fmt.Errorf("update communication service: %w", err) + } + if err := s.replaceServiceAliasesTx(ctx, tx, id, input.Aliases); err != nil { + return err + } + service, err = getCommunicationServiceTx(ctx, tx, id) + return err + }) + return service, err +} + +func (s *Store) SetCommunicationServiceActiveContext(ctx context.Context, id int64, active bool) (*CommunicationService, error) { + result, err := s.db.ExecContext(ctx, `UPDATE communication_services + SET is_active = ?, updated_at = `+s.dialect.Now()+` WHERE id = ?`, active, id) + if err != nil { + return nil, fmt.Errorf("set communication service active: %w", err) + } + changed, err := result.RowsAffected() + if err != nil { + return nil, fmt.Errorf("check communication service update: %w", err) + } + if changed == 0 { + return nil, ErrServiceNotFound + } + return s.GetCommunicationServiceContext(ctx, id) +} + +// NormalizeServiceValue applies the service's versioned lookup strategy. +func NormalizeServiceValue(service *CommunicationService, addressKind ContactAddressKind, raw string) (string, error) { + value := strings.TrimSpace(raw) + if value == "" { + return "", ErrNormalizationRejected + } + var strategy string + if service != nil { + strategy = service.Normalization + } else { + switch addressKind { + case ContactAddressEmail: + strategy = NormalizationEmail + case ContactAddressPhone: + strategy = NormalizationPhoneE164 + case ContactAddressLanguage: + strategy = NormalizationLower + default: + strategy = NormalizationNone + } + } + if strategy == NormalizationByAddressKind { + switch addressKind { + case ContactAddressEmail: + strategy = NormalizationEmail + case ContactAddressPhone: + strategy = NormalizationPhoneE164 + default: + strategy = NormalizationNone + } + } + switch strategy { + case NormalizationNone: + return value, nil + case NormalizationLower, NormalizationEmail: + return strings.ToLower(value), nil + case NormalizationStripAtLower: + return strings.ToLower(strings.TrimPrefix(value, "@")), nil + case NormalizationPhoneE164: + normalized, err := textimport.NormalizePhone(value) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrNormalizationRejected, err) + } + return normalized, nil + default: + return "", ErrInvalidNormalization + } +} + +func ValidateServiceScope(service *CommunicationService, scopeKind, scopeValue *string) error { + hasKind := scopeKind != nil && strings.TrimSpace(*scopeKind) != "" + hasValue := scopeValue != nil && strings.TrimSpace(*scopeValue) != "" + if service != nil { + switch service.ScopePolicy { + case ScopePolicyRequired: + if !hasKind || !hasValue { + return ErrServiceScopeRequired + } + case ScopePolicyNone: + if hasKind || hasValue { + return ErrServiceScopeForbidden + } + } + } + // A scope kind without a value (or the reverse) would fragment identity + // keys: the same address would land under distinct half-scoped keys. + if hasKind != hasValue { + return ErrServiceScopeIncomplete + } + return nil +} + +// trimmedOrNil trims an optional identity-key part (scope kind, scope value, +// normalized value, or provider user ID) and treats blank input as absent, so +// blank-vs-NULL and padded variants cannot fragment identity keys. +func trimmedOrNil(value *string) *string { + if value == nil { + return nil + } + trimmed := strings.TrimSpace(*value) + if trimmed == "" { + return nil + } + return &trimmed +} + +func (s *Store) seedCommunicationServices(ctx context.Context) error { + return s.withTxContext(ctx, func(tx *loggedTx) error { + var applied int + if err := tx.QueryRowContext(ctx, + `SELECT COUNT(*) FROM applied_migrations WHERE name = ?`, + communicationServicesSeedV1, + ).Scan(&applied); err != nil { + return fmt.Errorf("check communication service seed: %w", err) + } + if applied > 0 { + return nil + } + insert := s.dialect.InsertOrIgnore(`INSERT OR IGNORE INTO communication_services ( + slug, display_label, scope_policy, default_scope_kind, normalization, + normalization_version, uri_scheme, profile_url_template, is_system + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, TRUE)`) + for _, input := range seededCommunicationServices { + if _, err := tx.ExecContext(ctx, insert, + input.Slug, input.DisplayLabel, input.ScopePolicy, stringValue(input.DefaultScopeKind), + input.Normalization, input.NormalizationVersion, stringValue(input.URIScheme), + stringValue(input.ProfileURLTemplate), + ); err != nil { + return fmt.Errorf("seed communication service %q: %w", input.Slug, err) + } + service, err := getCommunicationServiceBySlugTx(ctx, tx, input.Slug) + if err != nil { + return err + } + for _, alias := range input.Aliases { + if _, err := tx.ExecContext(ctx, + s.dialect.InsertOrIgnore(`INSERT OR IGNORE INTO communication_service_aliases (alias, service_id) VALUES (?, ?)`), + strings.ToLower(alias), service.ID, + ); err != nil { + return fmt.Errorf("seed communication service alias %q: %w", alias, err) + } + } + } + if _, err := tx.ExecContext(ctx, + s.dialect.InsertOrIgnore(`INSERT OR IGNORE INTO applied_migrations (name) VALUES (?)`), + communicationServicesSeedV1, + ); err != nil { + return fmt.Errorf("record communication service seed: %w", err) + } + return nil + }) +} + +const serviceSelect = `SELECT id, slug, display_label, scope_policy, default_scope_kind, + normalization, normalization_version, uri_scheme, profile_url_template, + is_system, is_active, created_at, updated_at + FROM communication_services` + +func scanCommunicationService(row scanner) (*CommunicationService, error) { + var service CommunicationService + var defaultScopeKind, uriScheme, profileURLTemplate sql.NullString + if err := row.Scan( + &service.ID, &service.Slug, &service.DisplayLabel, &service.ScopePolicy, + &defaultScopeKind, &service.Normalization, &service.NormalizationVersion, + &uriScheme, &profileURLTemplate, &service.IsSystem, &service.IsActive, + &service.CreatedAt, &service.UpdatedAt, + ); err != nil { + return nil, err + } + service.DefaultScopeKind = nullStringPtr(defaultScopeKind) + service.URIScheme = nullStringPtr(uriScheme) + service.ProfileURLTemplate = nullStringPtr(profileURLTemplate) + return &service, nil +} + +func getCommunicationServiceTx(ctx context.Context, tx *loggedTx, id int64) (*CommunicationService, error) { + service, err := scanCommunicationService(tx.QueryRowContext(ctx, serviceSelect+` WHERE id = ?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrServiceNotFound + } + if err != nil { + return nil, fmt.Errorf("get communication service: %w", err) + } + service.Aliases, err = loadServiceAliasesTx(ctx, tx, id) + return service, err +} + +func getCommunicationServiceBySlugTx(ctx context.Context, tx *loggedTx, slug string) (*CommunicationService, error) { + service, err := scanCommunicationService(tx.QueryRowContext(ctx, serviceSelect+` WHERE slug = ?`, slug)) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrServiceNotFound + } + if err != nil { + return nil, fmt.Errorf("get communication service by slug: %w", err) + } + service.Aliases, err = loadServiceAliasesTx(ctx, tx, service.ID) + return service, err +} + +func (s *Store) loadServiceAliasesContext(ctx context.Context, serviceID int64) ([]string, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT alias FROM communication_service_aliases WHERE service_id = ? ORDER BY alias`, + serviceID, + ) + if err != nil { + return nil, fmt.Errorf("load communication service aliases: %w", err) + } + return scanAliases(rows) +} + +func loadServiceAliasesTx(ctx context.Context, tx *loggedTx, serviceID int64) ([]string, error) { + rows, err := tx.QueryContext(ctx, + `SELECT alias FROM communication_service_aliases WHERE service_id = ? ORDER BY alias`, + serviceID, + ) + if err != nil { + return nil, fmt.Errorf("load communication service aliases: %w", err) + } + return scanAliases(rows) +} + +func scanAliases(rows *loggedRows) ([]string, error) { + defer func() { _ = rows.Close() }() + aliases := make([]string, 0) + for rows.Next() { + var alias string + if err := rows.Scan(&alias); err != nil { + return nil, fmt.Errorf("scan communication service alias: %w", err) + } + aliases = append(aliases, alias) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("load communication service aliases: %w", err) + } + return aliases, nil +} + +func (s *Store) loadAllServiceAliasesContext(ctx context.Context) (map[int64][]string, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT service_id, alias FROM communication_service_aliases ORDER BY service_id, alias`, + ) + if err != nil { + return nil, fmt.Errorf("load communication service aliases: %w", err) + } + defer func() { _ = rows.Close() }() + aliases := make(map[int64][]string) + for rows.Next() { + var serviceID int64 + var alias string + if err := rows.Scan(&serviceID, &alias); err != nil { + return nil, fmt.Errorf("scan communication service alias: %w", err) + } + aliases[serviceID] = append(aliases[serviceID], alias) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("load communication service aliases: %w", err) + } + return aliases, nil +} + +func validateCommunicationServiceInput(input CommunicationServiceInput) error { + if !serviceSlugPattern.MatchString(input.Slug) { + return ErrInvalidServiceSlug + } + switch input.ScopePolicy { + case ScopePolicyNone, ScopePolicyOptional, ScopePolicyRequired: + default: + return ErrInvalidScopePolicy + } + switch input.Normalization { + case NormalizationNone, NormalizationLower, NormalizationEmail, + NormalizationPhoneE164, NormalizationStripAtLower, NormalizationByAddressKind: + default: + return ErrInvalidNormalization + } + if strings.TrimSpace(input.DisplayLabel) == "" || input.NormalizationVersion < 1 { + return ErrInvalidNormalization + } + return nil +} + +func ensureAliasesAvailableTx(ctx context.Context, tx *loggedTx, serviceID int64, aliases []string) error { + for _, raw := range aliases { + alias := strings.ToLower(strings.TrimSpace(raw)) + if alias == "" { + return ErrServiceAliasConflict + } + var slugOwner int64 + err := tx.QueryRowContext(ctx, + `SELECT id FROM communication_services WHERE slug = ?`, alias, + ).Scan(&slugOwner) + if err == nil && slugOwner != serviceID { + return ErrServiceAliasConflict + } + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("check communication service alias slug: %w", err) + } + var aliasOwner int64 + err = tx.QueryRowContext(ctx, + `SELECT service_id FROM communication_service_aliases WHERE alias = ?`, alias, + ).Scan(&aliasOwner) + if err == nil && aliasOwner != serviceID { + return ErrServiceAliasConflict + } + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("check communication service alias: %w", err) + } + } + return nil +} + +func (s *Store) replaceServiceAliasesTx( + ctx context.Context, tx *loggedTx, serviceID int64, aliases []string, +) error { + if _, err := tx.ExecContext(ctx, + `DELETE FROM communication_service_aliases WHERE service_id = ?`, serviceID, + ); err != nil { + return fmt.Errorf("replace communication service aliases: %w", err) + } + unique := make(map[string]struct{}, len(aliases)) + for _, raw := range aliases { + alias := strings.ToLower(strings.TrimSpace(raw)) + unique[alias] = struct{}{} + } + sorted := make([]string, 0, len(unique)) + for alias := range unique { + sorted = append(sorted, alias) + } + sort.Strings(sorted) + for _, alias := range sorted { + if _, err := tx.ExecContext(ctx, + `INSERT INTO communication_service_aliases (alias, service_id) VALUES (?, ?)`, + alias, serviceID, + ); err != nil { + if s.dialect.IsConflictError(err) { + return ErrServiceAliasConflict + } + return fmt.Errorf("insert communication service alias %q: %w", alias, err) + } + } + return nil +} + +func (s *Store) lockCommunicationServiceNamesTx( + ctx context.Context, tx *loggedTx, input CommunicationServiceInput, +) error { + names := make(map[string]struct{}, len(input.Aliases)+1) + names[input.Slug] = struct{}{} + for _, raw := range input.Aliases { + names[strings.ToLower(strings.TrimSpace(raw))] = struct{}{} + } + ordered := make([]string, 0, len(names)) + for name := range names { + ordered = append(ordered, name) + } + sort.Strings(ordered) + for _, name := range ordered { + if err := s.lockProfileIdentityKeyTxContext( + ctx, tx, "communication-service-name", name, + ); err != nil { + return err + } + } + return nil +} diff --git a/internal/store/communication_services_test.go b/internal/store/communication_services_test.go new file mode 100644 index 000000000..84037e399 --- /dev/null +++ b/internal/store/communication_services_test.go @@ -0,0 +1,318 @@ +package store_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil/storetest" +) + +func TestSeededServiceCatalogCoversTheRoadmapSet(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + + services, err := st.ListCommunicationServicesContext(context.Background(), true) + require.NoError(err) + bySlug := make(map[string]store.CommunicationService, len(services)) + for _, service := range services { + bySlug[service.Slug] = service + } + for _, slug := range []string{ + "whatsapp", "telegram", "facebook", "messenger", "instagram", "signal", + "x", "discord", "slack", "linkedin", "sms", "rcs", "google-messages", + "google-voice", "google-chat", "irc", "groupme", "imessage", "line", + "bluesky", "matrix", "reddit", "kakaotalk", "wechat", + } { + service, ok := bySlug[slug] + assert.True(ok, "seeded service %q must exist", slug) + assert.True(service.IsSystem, "seeded service %q must be system-owned", slug) + assert.True(service.IsActive, "seeded service %q must be active", slug) + } + assert.Equal(store.ScopePolicyRequired, bySlug["slack"].ScopePolicy) + assert.Equal(store.NormalizationPhoneE164, bySlug["whatsapp"].Normalization) + assert.Equal(store.NormalizationNone, bySlug["matrix"].Normalization) +} + +func TestServiceAliasesResolveToOneCanonicalService(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + + for _, test := range []struct{ lookup, want string }{ + {"twitter", "x"}, {"X", "x"}, {"gmessages", "google-messages"}, + {"bsky", "bluesky"}, {"bluesky", "bluesky"}, + } { + service, err := st.ResolveCommunicationServiceContext(ctx, test.lookup) + require.NoError(err, test.lookup) + assert.Equal(test.want, service.Slug, test.lookup) + } + _, err := st.ResolveCommunicationServiceContext(ctx, "no-such-bridge") + require.ErrorIs(err, store.ErrServiceNotFound) +} + +func TestUnknownServiceIsRegisteredWithoutASchemaMigration(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + + created, isNew, err := st.EnsureCommunicationServiceContext(ctx, store.CommunicationServiceInput{ + Slug: "example-bridge", DisplayLabel: "Example Bridge", + Aliases: []string{"examplebridge"}, ScopePolicy: store.ScopePolicyOptional, + Normalization: store.NormalizationLower, NormalizationVersion: 1, + }) + require.NoError(err) + assert.True(isNew) + assert.False(created.IsSystem) + + again, isNew, err := st.EnsureCommunicationServiceContext(ctx, store.CommunicationServiceInput{ + Slug: "example-bridge", DisplayLabel: "Example Bridge", + ScopePolicy: store.ScopePolicyOptional, + Normalization: store.NormalizationLower, NormalizationVersion: 1, + }) + require.NoError(err) + assert.False(isNew) + assert.Equal(created.ID, again.ID) + resolved, err := st.ResolveCommunicationServiceContext(ctx, "examplebridge") + require.NoError(err) + assert.Equal("example-bridge", resolved.Slug) +} + +func TestServiceSeedIsIdempotentAndPreservesUserEdits(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + + service, err := st.ResolveCommunicationServiceContext(ctx, "slack") + require.NoError(err) + renamed, err := st.UpdateCommunicationServiceContext(ctx, service.ID, store.CommunicationServiceInput{ + Slug: "slack", DisplayLabel: "Work Chat", + ScopePolicy: store.ScopePolicyRequired, DefaultScopeKind: new("workspace"), + Normalization: store.NormalizationLower, NormalizationVersion: 1, + }) + require.NoError(err) + assert.Equal("Work Chat", renamed.DisplayLabel) + require.NoError(st.InitSchema()) + after, err := st.ResolveCommunicationServiceContext(ctx, "slack") + require.NoError(err) + assert.Equal("Work Chat", after.DisplayLabel) + assert.Equal(service.ID, after.ID) +} + +func TestReferencedServiceNormalizationIsImmutable(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := t.Context() + + service, created, err := st.EnsureCommunicationServiceContext( + ctx, store.CommunicationServiceInput{ + Slug: "immutable-normalization", DisplayLabel: "Immutable Normalization", + ScopePolicy: store.ScopePolicyNone, Normalization: store.NormalizationLower, + NormalizationVersion: 1, + }, + ) + require.NoError(err) + require.True(created) + personID := newTestPerson(t, st) + _, err = st.AddPersonContactPointContext(ctx, personID, store.PersonContactPointInput{ + AddressKind: store.ContactAddressUsername, ServiceSlug: new(service.Slug), + OriginalValue: "@Alice", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(err) + participantID, err := st.EnsureParticipantByIdentifier( + "example", "normalization-observation", "Observed", + ) + require.NoError(err) + _, err = st.RecordContactObservationContext( + ctx, participantID, store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressUsername, ServiceSlug: new(service.Slug), + OriginalValue: "@Bob", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + }, + ) + require.NoError(err) + + _, err = st.UpdateCommunicationServiceContext( + ctx, service.ID, store.CommunicationServiceInput{ + Slug: service.Slug, DisplayLabel: service.DisplayLabel, + ScopePolicy: service.ScopePolicy, Normalization: store.NormalizationStripAtLower, + NormalizationVersion: 1, + }, + ) + require.ErrorIs(err, store.ErrServiceNormalizationImmutable) + _, err = st.UpdateCommunicationServiceContext( + ctx, service.ID, store.CommunicationServiceInput{ + Slug: service.Slug, DisplayLabel: service.DisplayLabel, + ScopePolicy: service.ScopePolicy, Normalization: service.Normalization, + NormalizationVersion: 2, + }, + ) + require.ErrorIs(err, store.ErrServiceNormalizationImmutable) + + unchanged, err := st.GetCommunicationServiceContext(ctx, service.ID) + require.NoError(err) + assert.Equal(store.NormalizationLower, unchanged.Normalization) + assert.Equal(1, unchanged.NormalizationVersion) + profile, err := st.GetPersonProfileContext(ctx, personID) + require.NoError(err) + require.Len(profile.ContactPoints, 1) + assert.Equal("@alice", profile.ContactPoints[0].NormalizedValue) + observations, err := st.ListParticipantObservationsContext(ctx, participantID, true) + require.NoError(err) + require.Len(observations, 1) + assert.Equal("@bob", observations[0].NormalizedValue) +} + +func TestServiceAliasCannotBeStolenFromAnotherService(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + + _, _, err := st.EnsureCommunicationServiceContext(ctx, store.CommunicationServiceInput{ + Slug: "other-bridge", DisplayLabel: "Other Bridge", Aliases: []string{"twitter"}, + ScopePolicy: store.ScopePolicyNone, Normalization: store.NormalizationLower, + NormalizationVersion: 1, + }) + require.Error(err) + require.ErrorIs(err, store.ErrServiceAliasConflict) + resolved, err := st.ResolveCommunicationServiceContext(ctx, "twitter") + require.NoError(err) + assert.Equal("x", resolved.Slug) +} + +func TestCanonicalServiceSlugCannotShadowExistingAlias(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + + _, _, err := st.EnsureCommunicationServiceContext(ctx, store.CommunicationServiceInput{ + Slug: "twitter", DisplayLabel: "Twitter", + ScopePolicy: store.ScopePolicyNone, Normalization: store.NormalizationLower, + NormalizationVersion: 1, + }) + require.ErrorIs(err, store.ErrServiceAliasConflict) + + resolved, err := st.ResolveCommunicationServiceContext(ctx, "twitter") + require.NoError(err) + assert.Equal("x", resolved.Slug) +} + +func TestNormalizeServiceValuePerStrategy(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + + tests := []struct { + service string + addressKind store.ContactAddressKind + raw, want string + wantErr bool + }{ + {"whatsapp", store.ContactAddressPhone, "+1 (202) 555-0123", "+12025550123", false}, + {"signal", store.ContactAddressPhone, "202-555-0123", "+12025550123", false}, + {"whatsapp", store.ContactAddressPhone, "alice@example.com", "", true}, + {"x", store.ContactAddressUsername, "@Alice", "alice", false}, + {"bluesky", store.ContactAddressUsername, "@Alice.bsky.social", "alice.bsky.social", false}, + {"discord", store.ContactAddressUsername, "Alice", "alice", false}, + {"google-chat", store.ContactAddressEmail, "Alice@Example.com", "alice@example.com", false}, + {"matrix", store.ContactAddressUsername, "@Alice:example.org", "@Alice:example.org", false}, + {"imessage", store.ContactAddressEmail, "Alice@Example.com", "alice@example.com", false}, + {"imessage", store.ContactAddressPhone, "202-555-0123", "+12025550123", false}, + } + for _, test := range tests { + service, err := st.ResolveCommunicationServiceContext(ctx, test.service) + require.NoError(err) + got, err := store.NormalizeServiceValue(service, test.addressKind, test.raw) + if test.wantErr { + require.ErrorIs(err, store.ErrNormalizationRejected) + continue + } + require.NoError(err) + assert.Equal(test.want, got) + } +} + +func TestValidateServiceScopeFollowsScopePolicy(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + + slack, err := st.ResolveCommunicationServiceContext(ctx, "slack") + require.NoError(err) + whatsapp, err := st.ResolveCommunicationServiceContext(ctx, "whatsapp") + require.NoError(err) + require.ErrorIs(store.ValidateServiceScope(slack, nil, nil), store.ErrServiceScopeRequired) + assert.NoError(store.ValidateServiceScope(slack, new("workspace"), new("T0EXAMPLE"))) + assert.NoError(store.ValidateServiceScope(whatsapp, nil, nil)) + require.ErrorIs( + store.ValidateServiceScope(whatsapp, new("workspace"), new("T0EXAMPLE")), + store.ErrServiceScopeForbidden, + ) +} + +func TestValidateServiceScopeRejectsHalfScopes(t *testing.T) { + require := require.New(t) + st := storetest.New(t).Store + ctx := context.Background() + + googleChat, err := st.ResolveCommunicationServiceContext(ctx, "google-chat") + require.NoError(err) + require.Equal(store.ScopePolicyOptional, googleChat.ScopePolicy) + require.NoError(store.ValidateServiceScope(googleChat, nil, nil)) + require.NoError(store.ValidateServiceScope(googleChat, new("account"), new("user@example.com"))) + require.ErrorIs( + store.ValidateServiceScope(googleChat, new("account"), nil), + store.ErrServiceScopeIncomplete, + ) + require.ErrorIs( + store.ValidateServiceScope(googleChat, nil, new("user@example.com")), + store.ErrServiceScopeIncomplete, + ) + require.ErrorIs( + store.ValidateServiceScope(nil, new("workspace"), nil), + store.ErrServiceScopeIncomplete, + ) + require.ErrorIs( + store.ValidateServiceScope(nil, nil, new("T0EXAMPLE")), + store.ErrServiceScopeIncomplete, + ) +} + +func TestBlankScopeStringsDoNotFragmentObservationIdentity(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + participantID, err := st.EnsureParticipantByIdentifier( + "example", "blank-scope", "Blank Scope", + ) + require.NoError(err) + input := store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressEmail, OriginalValue: "scoped@example.org", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + } + first, err := st.RecordContactObservationContext(ctx, participantID, input) + require.NoError(err) + require.True(first.Created) + + input.ScopeKind, input.ScopeValue = new(" "), new("") + second, err := st.RecordContactObservationContext(ctx, participantID, input) + require.NoError(err) + assert.False(second.Created, + "blank scope strings must resolve to the existing unscoped observation") + assert.Equal(first.Observation.Envelope.ID, second.Observation.Envelope.ID) + assert.Nil(second.Observation.ScopeKind) + assert.Nil(second.Observation.ScopeValue) +} diff --git a/internal/store/dialect_pg.go b/internal/store/dialect_pg.go index a17d7e714..180094a4e 100644 --- a/internal/store/dialect_pg.go +++ b/internal/store/dialect_pg.go @@ -599,6 +599,11 @@ func (d *PostgreSQLDialect) LegacyColumnMigrations() []ColumnMigration { {`ALTER TABLE conversations ADD COLUMN IF NOT EXISTS title TEXT`, "title"}, {`ALTER TABLE conversations ADD COLUMN IF NOT EXISTS conversation_type TEXT NOT NULL DEFAULT 'email_thread'`, "conversation_type"}, {`ALTER TABLE labels ADD COLUMN IF NOT EXISTS system_role TEXT`, "labels.system_role"}, + {`ALTER TABLE participant_identifiers ADD COLUMN IF NOT EXISTS service_id BIGINT REFERENCES communication_services(id) ON DELETE SET NULL`, "pi_service_id"}, + {`ALTER TABLE participant_identifiers ADD COLUMN IF NOT EXISTS scope_kind TEXT`, "pi_scope_kind"}, + {`ALTER TABLE participant_identifiers ADD COLUMN IF NOT EXISTS scope_value TEXT`, "pi_scope_value"}, + {`ALTER TABLE identity_match_candidates ADD COLUMN IF NOT EXISTS observation_conflict_origin TEXT CHECK (observation_conflict_origin IN ('generated', 'promoted'))`, "identity_match_candidates.observation_conflict_origin"}, + {`ALTER TABLE identity_match_candidates ADD COLUMN IF NOT EXISTS pre_conflict_state TEXT CHECK (pre_conflict_state IN ('candidate', 'accepted', 'rejected'))`, "identity_match_candidates.pre_conflict_state"}, // FTS tsvector column for legacy PG databases created before FTS // support. Inline in schema_pg.sql's CREATE TABLE (a no-op on a // pre-existing table), so without this an upgraded DB never gets the @@ -875,7 +880,9 @@ func (d *PostgreSQLDialect) IsFTSValueTooLargeError(err error) bool { // (verified against internal/store/messages.go, internal/store/sync.go, // internal/store/account_identities.go, internal/store/migrations.go, and // internal/sync/*.go) PLUS every table reached by ON DELETE CASCADE when -// RemoveSourceSerialized deletes a source. +// RemoveSourceSerialized deletes a source. The list also includes the +// identity candidate/evidence tables reached by explicit polymorphic endpoint +// cleanup before the source cascade. // // Invariant: every table with an ON DELETE CASCADE foreign-key chain to // sources(id) MUST appear here, otherwise the cascade DELETE can race a @@ -896,6 +903,7 @@ var exclusiveLockTables = []string{ "sync_runs", "sources", "conversations", "conversation_participants", "messages", "message_recipients", "message_labels", "message_bodies", "message_raw", "attachments", "labels", "participants", "participant_identifiers", "reactions", + "participant_contact_observations", "identity_match_candidates", "identity_match_evidence", // persons and person_participants: MergeParticipants (reached from the // Beeper import path) repoints bindings and bumps person revisions, so // both belong to the sync/import write set this lock mirrors. diff --git a/internal/store/dialect_sqlite.go b/internal/store/dialect_sqlite.go index 9723d9ac6..0db176c53 100644 --- a/internal/store/dialect_sqlite.go +++ b/internal/store/dialect_sqlite.go @@ -775,6 +775,11 @@ func (d *SQLiteDialect) LegacyColumnMigrations() []ColumnMigration { {`ALTER TABLE conversations ADD COLUMN title TEXT`, "title"}, {`ALTER TABLE conversations ADD COLUMN conversation_type TEXT NOT NULL DEFAULT 'email_thread'`, "conversation_type"}, {`ALTER TABLE labels ADD COLUMN system_role TEXT`, "labels.system_role"}, + {`ALTER TABLE participant_identifiers ADD COLUMN service_id INTEGER REFERENCES communication_services(id) ON DELETE SET NULL`, "pi_service_id"}, + {`ALTER TABLE participant_identifiers ADD COLUMN scope_kind TEXT`, "pi_scope_kind"}, + {`ALTER TABLE participant_identifiers ADD COLUMN scope_value TEXT`, "pi_scope_value"}, + {`ALTER TABLE identity_match_candidates ADD COLUMN observation_conflict_origin TEXT CHECK (observation_conflict_origin IN ('generated', 'promoted'))`, "identity_match_candidates.observation_conflict_origin"}, + {`ALTER TABLE identity_match_candidates ADD COLUMN pre_conflict_state TEXT CHECK (pre_conflict_state IN ('candidate', 'accepted', 'rejected'))`, "identity_match_candidates.pre_conflict_state"}, // embed_gen: per-message vector-embedding watermark. NULL default // means every legacy row reads as "needs embedding", which is // correct — the scan-and-fill worker (and backstop) will embed and diff --git a/internal/store/identity_match_candidates.go b/internal/store/identity_match_candidates.go new file mode 100644 index 000000000..2720b4186 --- /dev/null +++ b/internal/store/identity_match_candidates.go @@ -0,0 +1,963 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "sort" + "strings" + "time" +) + +type IdentityMatchEndpointKind string + +const ( + IdentityMatchParticipant IdentityMatchEndpointKind = "participant" + IdentityMatchPerson IdentityMatchEndpointKind = "person" + IdentityMatchObservation IdentityMatchEndpointKind = "observation" + IdentityMatchContactPoint IdentityMatchEndpointKind = "contact_point" +) + +func (k IdentityMatchEndpointKind) valid() bool { + switch k { + case IdentityMatchParticipant, IdentityMatchPerson, + IdentityMatchObservation, IdentityMatchContactPoint: + return true + default: + return false + } +} + +type IdentityMatchBasis string + +const ( + IdentityMatchStableProviderID IdentityMatchBasis = "stable_provider_id" + IdentityMatchServiceScopeUsername IdentityMatchBasis = "service_scope_username" + IdentityMatchEmail IdentityMatchBasis = "email" + IdentityMatchPhone IdentityMatchBasis = "phone" + IdentityMatchDisplayName IdentityMatchBasis = "display_name" + IdentityMatchConversationMembership IdentityMatchBasis = "conversation_membership" +) + +func (b IdentityMatchBasis) valid() bool { + switch b { + case IdentityMatchStableProviderID, IdentityMatchServiceScopeUsername, + IdentityMatchEmail, IdentityMatchPhone, IdentityMatchDisplayName, + IdentityMatchConversationMembership: + return true + default: + return false + } +} + +type IdentityMatchState string + +const ( + IdentityMatchStateCandidate IdentityMatchState = "candidate" + IdentityMatchStateAccepted IdentityMatchState = "accepted" + IdentityMatchStateRejected IdentityMatchState = "rejected" + IdentityMatchStateConflict IdentityMatchState = "conflict" + + observationConflictOriginGenerated = "generated" + observationConflictOriginPromoted = "promoted" +) + +func (s IdentityMatchState) valid() bool { + switch s { + case IdentityMatchStateCandidate, IdentityMatchStateAccepted, + IdentityMatchStateRejected, IdentityMatchStateConflict: + return true + default: + return false + } +} + +type IdentityMatchCandidate struct { + ID int64 `json:"id"` + LeftKind IdentityMatchEndpointKind `json:"left_kind"` + LeftID int64 `json:"left_id"` + RightKind IdentityMatchEndpointKind `json:"right_kind"` + RightID int64 `json:"right_id"` + Basis IdentityMatchBasis `json:"basis"` + ServiceSlug *string `json:"service_slug,omitempty"` + ScopeKind *string `json:"scope_kind,omitempty"` + ScopeValue *string `json:"scope_value,omitempty"` + NormalizedValue *string `json:"normalized_value,omitempty"` + State IdentityMatchState `json:"state"` + Confidence *float64 `json:"confidence,omitempty"` + Source Provenance `json:"source"` + SourceRef *string `json:"source_ref,omitempty"` + DecidedBy *string `json:"decided_by,omitempty"` + DecidedAt *time.Time `json:"decided_at,omitempty"` + Notes *string `json:"notes,omitempty"` + Evidence []IdentityMatchEvidence `json:"evidence"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type IdentityMatchEvidence struct { + ID int64 `json:"id"` + CandidateID int64 `json:"candidate_id"` + EvidenceKind string `json:"evidence_kind"` + EvidenceRef *string `json:"evidence_ref,omitempty"` + Detail *string `json:"detail,omitempty"` + Source Provenance `json:"source"` + CreatedAt time.Time `json:"created_at"` +} + +type IdentityMatchCandidateInput struct { + LeftKind IdentityMatchEndpointKind + LeftID int64 + RightKind IdentityMatchEndpointKind + RightID int64 + Basis IdentityMatchBasis + ServiceSlug *string + ScopeKind *string + ScopeValue *string + NormalizedValue *string + State IdentityMatchState + Confidence *float64 + Source Provenance + SourceRef *string + Notes *string +} + +type IdentityMatchEvidenceInput struct { + EvidenceKind string + EvidenceRef *string + Detail *string + Source Provenance +} + +var ( + ErrInvalidIdentityMatchEndpoint = errors.New("invalid identity match endpoint kind") + ErrInvalidIdentityMatchBasis = errors.New("invalid identity match basis") + ErrInvalidIdentityMatchState = errors.New("invalid identity match state") + ErrIdentityMatchSelfLink = errors.New("identity match endpoints must differ") + ErrIdentityMatchNotFound = errors.New("identity match candidate not found") + ErrIdentityMatchEndpointNotFound = errors.New("identity match endpoint not found") + ErrIdentityMatchNotAcceptable = errors.New("a username-only match requires stable provider corroboration or explicit confirmation") +) + +func (s *Store) UpsertIdentityMatchCandidateContext( + ctx context.Context, input IdentityMatchCandidateInput, +) (*IdentityMatchCandidate, bool, error) { + if !input.LeftKind.valid() || !input.RightKind.valid() { + return nil, false, ErrInvalidIdentityMatchEndpoint + } + if !input.Basis.valid() { + return nil, false, ErrInvalidIdentityMatchBasis + } + if !input.State.valid() { + return nil, false, ErrInvalidIdentityMatchState + } + if input.State != IdentityMatchStateCandidate && input.State != IdentityMatchStateConflict { + return nil, false, ErrInvalidIdentityMatchState + } + if !input.Source.Valid() { + return nil, false, ErrInvalidProvenance + } + if input.Confidence != nil { + if err := (ValueEnvelope{Source: input.Source, Confidence: input.Confidence}).Validate(); err != nil { + return nil, false, err + } + } + leftKind, leftID, rightKind, rightID, err := canonicalMatchEndpoints( + input.LeftKind, input.LeftID, input.RightKind, input.RightID, + ) + if err != nil { + return nil, false, err + } + input.ScopeKind = trimmedOrNil(input.ScopeKind) + input.ScopeValue = trimmedOrNil(input.ScopeValue) + input.NormalizedValue = trimmedOrNil(input.NormalizedValue) + service, hasService, err := s.resolveOptionalCommunicationServiceContext(ctx, input.ServiceSlug) + if err != nil { + return nil, false, err + } + if err := ValidateServiceScope(service, input.ScopeKind, input.ScopeValue); err != nil { + return nil, false, err + } + var serviceID any + if hasService { + serviceID = service.ID + } + var candidate *IdentityMatchCandidate + created := false + err = s.withTxContext(ctx, func(tx *loggedTx) error { + if err := s.lockIdentityMutationTxContext(ctx, tx); err != nil { + return err + } + if err := validateIdentityMatchEndpointTx(ctx, tx, leftKind, leftID); err != nil { + return err + } + if err := validateIdentityMatchEndpointTx(ctx, tx, rightKind, rightID); err != nil { + return err + } + candidate, created, err = s.upsertIdentityMatchCandidateTx( + ctx, tx, input, leftKind, leftID, rightKind, rightID, serviceID, false, + ) + return err + }) + return candidate, created, err +} + +func validateIdentityMatchEndpointTx( + ctx context.Context, tx *loggedTx, kind IdentityMatchEndpointKind, id int64, +) error { + var table string + switch kind { + case IdentityMatchParticipant: + table = "participants" + case IdentityMatchPerson: + table = "persons" + case IdentityMatchObservation: + table = "participant_contact_observations" + case IdentityMatchContactPoint: + table = "person_contact_points" + default: + return ErrInvalidIdentityMatchEndpoint + } + var exists int + if err := tx.QueryRowContext(ctx, + `SELECT COUNT(*) FROM `+table+` WHERE id = ?`, id, + ).Scan(&exists); err != nil { + return fmt.Errorf("validate %s identity match endpoint: %w", kind, err) + } + if exists == 0 { + return fmt.Errorf("%w: %s %d", ErrIdentityMatchEndpointNotFound, kind, id) + } + return nil +} + +func (s *Store) upsertIdentityMatchCandidateTx( + ctx context.Context, + tx *loggedTx, + input IdentityMatchCandidateInput, + leftKind IdentityMatchEndpointKind, + leftID int64, + rightKind IdentityMatchEndpointKind, + rightID int64, + serviceID any, + observationConflict bool, +) (*IdentityMatchCandidate, bool, error) { + if err := s.lockProfileIdentityKeyTxContext( + ctx, tx, "identity-match-candidate", + leftKind, leftID, rightKind, rightID, input.Basis, serviceID, + stringValue(input.ScopeKind), stringValue(input.ScopeValue), + stringValue(input.NormalizedValue), + ); err != nil { + return nil, false, err + } + candidate, err := findIdentityMatchCandidateTx( + ctx, tx, leftKind, leftID, rightKind, rightID, input.Basis, + serviceID, input.ScopeKind, input.ScopeValue, input.NormalizedValue, + s.dialect.SelectForUpdate(), + ) + if err == nil { + if candidate.State == IdentityMatchStateCandidate && + input.State == IdentityMatchStateConflict { + query := `UPDATE identity_match_candidates SET + state = ?, confidence = COALESCE(?, confidence), source = ?, source_ref = ?, + updated_at = ` + s.dialect.Now() + ` WHERE id = ?` + args := []any{ + input.State, floatValue(input.Confidence), input.Source, + stringValue(input.SourceRef), candidate.ID, + } + if observationConflict { + query = `UPDATE identity_match_candidates SET + state = ?, observation_conflict_origin = ?, + updated_at = ` + s.dialect.Now() + ` WHERE id = ?` + args = []any{ + input.State, observationConflictOriginPromoted, candidate.ID, + } + } + if _, err := tx.ExecContext(ctx, query, args...); err != nil { + return nil, false, fmt.Errorf("promote identity match candidate to conflict: %w", err) + } + candidate, err = getIdentityMatchCandidateTx(ctx, tx, candidate.ID) + return candidate, false, err + } + return candidate, false, nil + } + if !errors.Is(err, ErrIdentityMatchNotFound) { + return nil, false, err + } + var observationOrigin any + if observationConflict { + observationOrigin = observationConflictOriginGenerated + } + var id int64 + if err := tx.QueryRowContext(ctx, `INSERT INTO identity_match_candidates ( + left_kind, left_id, right_kind, right_id, basis, service_id, + scope_kind, scope_value, normalized_value, state, confidence, + source, source_ref, observation_conflict_origin, notes, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + `+s.dialect.Now()+`, `+s.dialect.Now()+`) RETURNING id`, + leftKind, leftID, rightKind, rightID, input.Basis, serviceID, + stringValue(input.ScopeKind), stringValue(input.ScopeValue), + stringValue(input.NormalizedValue), input.State, floatValue(input.Confidence), + input.Source, stringValue(input.SourceRef), observationOrigin, stringValue(input.Notes), + ).Scan(&id); err != nil { + return nil, false, fmt.Errorf("insert identity match candidate: %w", err) + } + candidate, err = getIdentityMatchCandidateTx(ctx, tx, id) + return candidate, err == nil, err +} + +func (s *Store) AddIdentityMatchEvidenceContext( + ctx context.Context, candidateID int64, input IdentityMatchEvidenceInput, +) (*IdentityMatchEvidence, error) { + kind := strings.TrimSpace(input.EvidenceKind) + if kind == "" || !input.Source.Valid() { + if !input.Source.Valid() { + return nil, ErrInvalidProvenance + } + return nil, errors.New("identity match evidence kind is required") + } + var evidence *IdentityMatchEvidence + err := s.withTxContext(ctx, func(tx *loggedTx) error { + if err := s.lockIdentityMutationTxContext(ctx, tx); err != nil { + return err + } + var exists int + if err := tx.QueryRowContext(ctx, + `SELECT COUNT(*) FROM identity_match_candidates WHERE id = ?`, candidateID, + ).Scan(&exists); err != nil { + return fmt.Errorf("check identity match candidate: %w", err) + } + if exists == 0 { + return ErrIdentityMatchNotFound + } + var id int64 + if err := tx.QueryRowContext(ctx, `INSERT INTO identity_match_evidence ( + candidate_id, evidence_kind, evidence_ref, detail, source + ) VALUES (?, ?, ?, ?, ?) RETURNING id`, + candidateID, kind, stringValue(input.EvidenceRef), + stringValue(input.Detail), input.Source, + ).Scan(&id); err != nil { + return fmt.Errorf("add identity match evidence: %w", err) + } + if _, err := tx.ExecContext(ctx, `UPDATE identity_match_candidates + SET updated_at = `+s.dialect.Now()+` WHERE id = ?`, candidateID, + ); err != nil { + return fmt.Errorf("touch identity match candidate: %w", err) + } + var err error + evidence, err = getIdentityMatchEvidenceTx(ctx, tx, id) + return err + }) + return evidence, err +} + +func (s *Store) ListIdentityMatchCandidatesContext( + ctx context.Context, states []IdentityMatchState, limit, offset int, +) ([]IdentityMatchCandidate, error) { + if limit <= 0 { + limit = 100 + } + if limit > 500 { + limit = 500 + } + if offset < 0 { + offset = 0 + } + args := make([]any, 0, len(states)+2) + query := identityMatchCandidateSelect + if len(states) > 0 { + placeholders := make([]string, len(states)) + for i, state := range states { + if !state.valid() { + return nil, ErrInvalidIdentityMatchState + } + placeholders[i] = "?" + args = append(args, state) + } + query += ` WHERE c.state IN (` + strings.Join(placeholders, ",") + `)` + } + query += ` ORDER BY c.id LIMIT ? OFFSET ?` + args = append(args, limit, offset) + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list identity match candidates: %w", err) + } + defer func() { _ = rows.Close() }() + candidates := make([]IdentityMatchCandidate, 0) + for rows.Next() { + candidate, err := scanIdentityMatchCandidate(rows) + if err != nil { + return nil, fmt.Errorf("scan identity match candidate: %w", err) + } + candidates = append(candidates, *candidate) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list identity match candidates: %w", err) + } + if err := s.loadCandidateEvidencePageContext(ctx, candidates); err != nil { + return nil, err + } + return candidates, nil +} + +func (s *Store) DecideIdentityMatchCandidateContext( + ctx context.Context, + candidateID int64, + state IdentityMatchState, + decidedBy string, + notes *string, +) (*IdentityMatchCandidate, error) { + if !state.valid() { + return nil, ErrInvalidIdentityMatchState + } + var candidate *IdentityMatchCandidate + err := s.withTxContext(ctx, func(tx *loggedTx) error { + if err := s.lockIdentityMutationTxContext(ctx, tx); err != nil { + return err + } + current, err := getIdentityMatchCandidateTx(ctx, tx, candidateID) + if err != nil { + return err + } + // Only a stable-provider-id candidate that records which stable ID + // matched may be accepted without explicit user confirmation; the basis + // label alone is caller-supplied and proves nothing. + if state == IdentityMatchStateAccepted && decidedBy != "user" && + (current.Basis != IdentityMatchStableProviderID || + current.NormalizedValue == nil || + strings.TrimSpace(*current.NormalizedValue) == "") { + return ErrIdentityMatchNotAcceptable + } + if _, err := tx.ExecContext(ctx, `UPDATE identity_match_candidates SET + state = ?, decided_by = ?, decided_at = `+s.dialect.Now()+`, + notes = ?, pre_conflict_state = NULL, + observation_conflict_origin = CASE WHEN ? + THEN NULL ELSE observation_conflict_origin END, + updated_at = `+s.dialect.Now()+` WHERE id = ?`, + state, decidedBy, stringValue(notes), + state == IdentityMatchStateConflict && decidedBy == "user", candidateID, + ); err != nil { + return fmt.Errorf("decide identity match candidate: %w", err) + } + candidate, err = getIdentityMatchCandidateTx(ctx, tx, candidateID) + return err + }) + return candidate, err +} + +type identityMatchCandidateMergeRow struct { + ID int64 + LeftKind IdentityMatchEndpointKind + LeftID int64 + RightKind IdentityMatchEndpointKind + RightID int64 + Basis IdentityMatchBasis + ServiceID sql.NullInt64 + ScopeKind sql.NullString + ScopeValue sql.NullString + NormalizedValue sql.NullString + State IdentityMatchState + Confidence sql.NullFloat64 + Source Provenance + SourceRef sql.NullString + ObservationConflictOrigin sql.NullString + PreConflictState sql.NullString + DecidedBy sql.NullString + DecidedAt sql.NullTime + Notes sql.NullString +} + +const identityMatchCandidateMergeSelect = `SELECT + id, left_kind, left_id, right_kind, right_id, basis, service_id, + scope_kind, scope_value, normalized_value, state, confidence, source, source_ref, + observation_conflict_origin, pre_conflict_state, decided_by, decided_at, notes + FROM identity_match_candidates` + +func scanIdentityMatchCandidateMergeRow(row scanner) (identityMatchCandidateMergeRow, error) { + var candidate identityMatchCandidateMergeRow + err := row.Scan( + &candidate.ID, &candidate.LeftKind, &candidate.LeftID, + &candidate.RightKind, &candidate.RightID, &candidate.Basis, + &candidate.ServiceID, &candidate.ScopeKind, &candidate.ScopeValue, + &candidate.NormalizedValue, &candidate.State, + &candidate.Confidence, &candidate.Source, + &candidate.SourceRef, &candidate.ObservationConflictOrigin, + &candidate.PreConflictState, &candidate.DecidedBy, + &candidate.DecidedAt, &candidate.Notes, + ) + return candidate, err +} + +func scanIdentityMatchCandidateMergeRows( + rows *loggedRows, +) ([]identityMatchCandidateMergeRow, error) { + defer func() { _ = rows.Close() }() + candidates := make([]identityMatchCandidateMergeRow, 0) + for rows.Next() { + candidate, err := scanIdentityMatchCandidateMergeRow(rows) + if err != nil { + return nil, err + } + candidates = append(candidates, candidate) + } + return candidates, rows.Err() +} + +func (s *Store) rewriteIdentityMatchCandidatesForMergeTx( + ctx context.Context, tx *loggedTx, oldID, newID int64, +) error { + rows, err := tx.QueryContext(ctx, identityMatchCandidateMergeSelect+` + WHERE (left_kind = ? AND left_id = ?) + OR (right_kind = ? AND right_id = ?) + ORDER BY id`+s.dialect.SelectForUpdate(), + IdentityMatchParticipant, oldID, IdentityMatchParticipant, oldID, + ) + if err != nil { + return fmt.Errorf("load identity match candidates for participant merge: %w", err) + } + candidates, err := scanIdentityMatchCandidateMergeRows(rows) + if err != nil { + return fmt.Errorf("scan identity match candidates for participant merge: %w", err) + } + + for _, candidate := range candidates { + if candidate.LeftKind == IdentityMatchParticipant && candidate.LeftID == oldID { + candidate.LeftID = newID + } + if candidate.RightKind == IdentityMatchParticipant && candidate.RightID == oldID { + candidate.RightID = newID + } + leftKind, leftID, rightKind, rightID, canonicalErr := canonicalMatchEndpoints( + candidate.LeftKind, candidate.LeftID, candidate.RightKind, candidate.RightID, + ) + if errors.Is(canonicalErr, ErrIdentityMatchSelfLink) { + if _, err := tx.ExecContext(ctx, + `DELETE FROM identity_match_candidates WHERE id = ?`, candidate.ID, + ); err != nil { + return fmt.Errorf("remove merged identity match self-link: %w", err) + } + continue + } + if canonicalErr != nil { + return canonicalErr + } + candidate.LeftKind, candidate.LeftID = leftKind, leftID + candidate.RightKind, candidate.RightID = rightKind, rightID + + collisions, err := s.loadIdentityMatchCandidateMergeCollisionsTx(ctx, tx, candidate) + if err != nil { + return err + } + group := append([]identityMatchCandidateMergeRow{candidate}, collisions...) + if len(group) == 1 { + if _, err := tx.ExecContext(ctx, `UPDATE identity_match_candidates SET + left_kind = ?, left_id = ?, right_kind = ?, right_id = ?, + updated_at = `+s.dialect.Now()+` WHERE id = ?`, + leftKind, leftID, rightKind, rightID, candidate.ID, + ); err != nil { + return fmt.Errorf("rewrite identity match candidate endpoints: %w", err) + } + continue + } + + if err := s.collapseIdentityMatchCandidateMergeGroupTx(ctx, tx, group); err != nil { + return err + } + } + return nil +} + +func (s *Store) loadIdentityMatchCandidateMergeCollisionsTx( + ctx context.Context, tx *loggedTx, candidate identityMatchCandidateMergeRow, +) ([]identityMatchCandidateMergeRow, error) { + rows, err := tx.QueryContext(ctx, identityMatchCandidateMergeSelect+` + WHERE id <> ? AND left_kind = ? AND left_id = ? + AND right_kind = ? AND right_id = ? AND basis = ? + AND (service_id = ? OR (service_id IS NULL AND CAST(? AS BIGINT) IS NULL)) + AND (scope_kind = ? OR (scope_kind IS NULL AND CAST(? AS TEXT) IS NULL)) + AND (scope_value = ? OR (scope_value IS NULL AND CAST(? AS TEXT) IS NULL)) + AND (normalized_value = ? OR + (normalized_value IS NULL AND CAST(? AS TEXT) IS NULL)) + ORDER BY id`+s.dialect.SelectForUpdate(), + candidate.ID, candidate.LeftKind, candidate.LeftID, + candidate.RightKind, candidate.RightID, candidate.Basis, + candidate.ServiceID, candidate.ServiceID, + candidate.ScopeKind, candidate.ScopeKind, + candidate.ScopeValue, candidate.ScopeValue, + candidate.NormalizedValue, candidate.NormalizedValue, + ) + if err != nil { + return nil, fmt.Errorf("load identity match candidate merge collisions: %w", err) + } + collisions, err := scanIdentityMatchCandidateMergeRows(rows) + if err != nil { + return nil, fmt.Errorf("scan identity match candidate merge collisions: %w", err) + } + return collisions, nil +} + +func (s *Store) collapseIdentityMatchCandidateMergeGroupTx( + ctx context.Context, tx *loggedTx, group []identityMatchCandidateMergeRow, +) error { + sort.Slice(group, func(i, j int) bool { return group[i].ID < group[j].ID }) + winner := group[0] + state, decidedBy, decidedAt, notes := reconcileIdentityMatchCandidateMergeState(group) + confidence, source, sourceRef := identityMatchCandidateMergeConfidenceProvenance(group) + observationOrigin := reconcileIdentityMatchCandidateMergeObservationOrigin(group, state) + preConflict := reconcileIdentityMatchCandidateMergePreConflictState(group, state) + + for _, loser := range group[1:] { + if _, err := tx.ExecContext(ctx, `UPDATE identity_match_evidence + SET candidate_id = ? WHERE candidate_id = ?`, winner.ID, loser.ID, + ); err != nil { + return fmt.Errorf("move identity match candidate evidence: %w", err) + } + if _, err := tx.ExecContext(ctx, + `DELETE FROM identity_match_candidates WHERE id = ?`, loser.ID, + ); err != nil { + return fmt.Errorf("remove duplicate identity match candidate: %w", err) + } + } + if _, err := tx.ExecContext(ctx, `UPDATE identity_match_candidates SET + left_kind = ?, left_id = ?, right_kind = ?, right_id = ?, state = ?, + confidence = ?, source = ?, source_ref = ?, + observation_conflict_origin = ?, pre_conflict_state = ?, + decided_by = ?, decided_at = ?, notes = ?, + updated_at = `+s.dialect.Now()+` WHERE id = ?`, + winner.LeftKind, winner.LeftID, winner.RightKind, winner.RightID, state, + confidence, source, sourceRef, observationOrigin, preConflict, + decidedBy, decidedAt, notes, winner.ID, + ); err != nil { + return fmt.Errorf("reconcile duplicate identity match candidate: %w", err) + } + return nil +} + +// reconcileIdentityMatchCandidateMergePreConflictState records which state a +// collapsed conflict should return to once its observation support is gone. +// A terminal decision that lost to a conflict during the collapse (or a +// pre-conflict state a group member already carried) is restorable; opposing +// decisions cancel out and fall back to an undecided candidate. +func reconcileIdentityMatchCandidateMergePreConflictState( + group []identityMatchCandidateMergeRow, + state IdentityMatchState, +) sql.NullString { + if state != IdentityMatchStateConflict { + return sql.NullString{} + } + restorable := sql.NullString{} + for _, candidate := range group { + value := "" + switch { + case candidate.State == IdentityMatchStateAccepted || + candidate.State == IdentityMatchStateRejected: + value = string(candidate.State) + case candidate.PreConflictState.Valid: + value = candidate.PreConflictState.String + } + if value == "" || value == string(IdentityMatchStateCandidate) { + continue + } + if restorable.Valid && restorable.String != value { + return sql.NullString{} + } + restorable = sql.NullString{String: value, Valid: true} + } + return restorable +} + +func reconcileIdentityMatchCandidateMergeObservationOrigin( + group []identityMatchCandidateMergeRow, + state IdentityMatchState, +) sql.NullString { + if state != IdentityMatchStateConflict { + return sql.NullString{} + } + hasObservationConflict := false + allGenerated := true + for _, candidate := range group { + if candidate.State == IdentityMatchStateConflict { + if !candidate.ObservationConflictOrigin.Valid { + return sql.NullString{} + } + hasObservationConflict = true + if candidate.ObservationConflictOrigin.String != observationConflictOriginGenerated { + allGenerated = false + } + continue + } + allGenerated = false + } + if !hasObservationConflict { + return sql.NullString{} + } + if allGenerated { + return sql.NullString{String: observationConflictOriginGenerated, Valid: true} + } + return sql.NullString{String: observationConflictOriginPromoted, Valid: true} +} + +func reconcileIdentityMatchCandidateMergeState( + group []identityMatchCandidateMergeRow, +) (IdentityMatchState, sql.NullString, sql.NullTime, sql.NullString) { + hasAccepted, hasRejected := false, false + state := IdentityMatchStateCandidate + for _, candidate := range group { + switch candidate.State { + case IdentityMatchStateCandidate: + // Candidate is the neutral state when terminal decisions are merged. + case IdentityMatchStateConflict: + state = IdentityMatchStateConflict + case IdentityMatchStateAccepted: + hasAccepted = true + case IdentityMatchStateRejected: + hasRejected = true + } + } + generatedConflict := state != IdentityMatchStateConflict && hasAccepted && hasRejected + if generatedConflict { + return IdentityMatchStateConflict, + sql.NullString{String: "system", Valid: true}, + sql.NullTime{Time: time.Now().UTC(), Valid: true}, + sql.NullString{ + String: "participant merge reconciled opposing identity decisions", + Valid: true, + } + } + if state != IdentityMatchStateConflict { + switch { + case hasAccepted: + state = IdentityMatchStateAccepted + case hasRejected: + state = IdentityMatchStateRejected + } + } + for _, candidate := range group { + if candidate.State == state && hasMergeDecisionMetadata(candidate) { + return state, candidate.DecidedBy, candidate.DecidedAt, candidate.Notes + } + } + // A conflict outranks terminal decisions when states collapse, but the + // review that produced those decisions must survive the merge: without + // this, a later conflict cleanup would demote the row to an undecided + // candidate with no trace of who decided it. + for _, candidate := range group { + if (candidate.State == IdentityMatchStateAccepted || + candidate.State == IdentityMatchStateRejected) && + hasMergeDecisionMetadata(candidate) { + return state, candidate.DecidedBy, candidate.DecidedAt, candidate.Notes + } + } + for _, candidate := range group { + if hasMergeDecisionMetadata(candidate) { + return state, candidate.DecidedBy, candidate.DecidedAt, candidate.Notes + } + } + return state, sql.NullString{}, sql.NullTime{}, sql.NullString{} +} + +func hasMergeDecisionMetadata(candidate identityMatchCandidateMergeRow) bool { + return candidate.DecidedBy.Valid || candidate.DecidedAt.Valid || candidate.Notes.Valid +} + +func identityMatchCandidateMergeConfidenceProvenance( + group []identityMatchCandidateMergeRow, +) (sql.NullFloat64, Provenance, sql.NullString) { + var confidence sql.NullFloat64 + source, sourceRef := group[0].Source, group[0].SourceRef + for _, candidate := range group { + if candidate.Confidence.Valid && + (!confidence.Valid || candidate.Confidence.Float64 > confidence.Float64) { + confidence = candidate.Confidence + source, sourceRef = candidate.Source, candidate.SourceRef + } + } + return confidence, source, sourceRef +} + +func canonicalMatchEndpoints( + leftKind IdentityMatchEndpointKind, + leftID int64, + rightKind IdentityMatchEndpointKind, + rightID int64, +) (IdentityMatchEndpointKind, int64, IdentityMatchEndpointKind, int64, error) { + if leftKind == rightKind && leftID == rightID { + return "", 0, "", 0, ErrIdentityMatchSelfLink + } + if string(leftKind) > string(rightKind) || + (leftKind == rightKind && leftID > rightID) { + return rightKind, rightID, leftKind, leftID, nil + } + return leftKind, leftID, rightKind, rightID, nil +} + +const identityMatchCandidateSelect = `SELECT + c.id, c.left_kind, c.left_id, c.right_kind, c.right_id, c.basis, + cs.slug, c.scope_kind, c.scope_value, c.normalized_value, c.state, + c.confidence, c.source, c.source_ref, c.decided_by, c.decided_at, + c.notes, c.created_at, c.updated_at + FROM identity_match_candidates c + LEFT JOIN communication_services cs ON cs.id = c.service_id` + +func findIdentityMatchCandidateTx( + ctx context.Context, + tx *loggedTx, + leftKind IdentityMatchEndpointKind, + leftID int64, + rightKind IdentityMatchEndpointKind, + rightID int64, + basis IdentityMatchBasis, + serviceID any, + scopeKind, scopeValue, normalizedValue *string, + lockClause string, +) (*IdentityMatchCandidate, error) { + var id int64 + err := tx.QueryRowContext(ctx, `SELECT id FROM identity_match_candidates + WHERE left_kind = ? AND left_id = ? + AND right_kind = ? AND right_id = ? AND basis = ? + AND (service_id = ? OR (service_id IS NULL AND CAST(? AS BIGINT) IS NULL)) + AND (scope_kind = ? OR (scope_kind IS NULL AND CAST(? AS TEXT) IS NULL)) + AND (scope_value = ? OR (scope_value IS NULL AND CAST(? AS TEXT) IS NULL)) + AND (normalized_value = ? OR + (normalized_value IS NULL AND CAST(? AS TEXT) IS NULL))`+lockClause, + leftKind, leftID, rightKind, rightID, basis, + serviceID, serviceID, + stringValue(scopeKind), stringValue(scopeKind), + stringValue(scopeValue), stringValue(scopeValue), + stringValue(normalizedValue), stringValue(normalizedValue), + ).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrIdentityMatchNotFound + } + if err != nil { + return nil, fmt.Errorf("find identity match candidate: %w", err) + } + return getIdentityMatchCandidateTx(ctx, tx, id) +} + +func getIdentityMatchCandidateTx( + ctx context.Context, tx *loggedTx, id int64, +) (*IdentityMatchCandidate, error) { + candidate, err := scanIdentityMatchCandidate(tx.QueryRowContext(ctx, + identityMatchCandidateSelect+` WHERE c.id = ?`, id, + )) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrIdentityMatchNotFound + } + if err != nil { + return nil, err + } + candidate.Evidence, err = loadCandidateEvidenceTx(ctx, tx, id) + return candidate, err +} + +func scanIdentityMatchCandidate(row scanner) (*IdentityMatchCandidate, error) { + var candidate IdentityMatchCandidate + var serviceSlug, scopeKind, scopeValue, normalizedValue sql.NullString + var confidence sql.NullFloat64 + var sourceRef, decidedBy, notes sql.NullString + var decidedAt sql.NullTime + if err := row.Scan( + &candidate.ID, &candidate.LeftKind, &candidate.LeftID, + &candidate.RightKind, &candidate.RightID, &candidate.Basis, + &serviceSlug, &scopeKind, &scopeValue, &normalizedValue, + &candidate.State, &confidence, &candidate.Source, &sourceRef, + &decidedBy, &decidedAt, ¬es, &candidate.CreatedAt, &candidate.UpdatedAt, + ); err != nil { + return nil, err + } + candidate.ServiceSlug = nullStringPtr(serviceSlug) + candidate.ScopeKind = nullStringPtr(scopeKind) + candidate.ScopeValue = nullStringPtr(scopeValue) + candidate.NormalizedValue = nullStringPtr(normalizedValue) + candidate.Confidence = nullFloatPtr(confidence) + candidate.SourceRef = nullStringPtr(sourceRef) + candidate.DecidedBy = nullStringPtr(decidedBy) + candidate.DecidedAt = nullTimePtr(decidedAt) + candidate.Notes = nullStringPtr(notes) + candidate.Evidence = []IdentityMatchEvidence{} + return &candidate, nil +} + +func getIdentityMatchEvidenceTx( + ctx context.Context, tx *loggedTx, id int64, +) (*IdentityMatchEvidence, error) { + var evidence IdentityMatchEvidence + var evidenceRef, detail sql.NullString + if err := tx.QueryRowContext(ctx, `SELECT + id, candidate_id, evidence_kind, evidence_ref, detail, source, created_at + FROM identity_match_evidence WHERE id = ?`, id, + ).Scan( + &evidence.ID, &evidence.CandidateID, &evidence.EvidenceKind, + &evidenceRef, &detail, &evidence.Source, &evidence.CreatedAt, + ); err != nil { + return nil, err + } + evidence.EvidenceRef = nullStringPtr(evidenceRef) + evidence.Detail = nullStringPtr(detail) + return &evidence, nil +} + +func loadCandidateEvidenceTx( + ctx context.Context, tx *loggedTx, candidateID int64, +) ([]IdentityMatchEvidence, error) { + rows, err := tx.QueryContext(ctx, `SELECT + id, candidate_id, evidence_kind, evidence_ref, detail, source, created_at + FROM identity_match_evidence WHERE candidate_id = ? ORDER BY id`, candidateID, + ) + if err != nil { + return nil, err + } + return scanIdentityMatchEvidenceRows(rows) +} + +func scanIdentityMatchEvidenceRows(rows *loggedRows) ([]IdentityMatchEvidence, error) { + defer func() { _ = rows.Close() }() + evidence := make([]IdentityMatchEvidence, 0) + for rows.Next() { + var item IdentityMatchEvidence + var evidenceRef, detail sql.NullString + if err := rows.Scan( + &item.ID, &item.CandidateID, &item.EvidenceKind, + &evidenceRef, &detail, &item.Source, &item.CreatedAt, + ); err != nil { + return nil, err + } + item.EvidenceRef = nullStringPtr(evidenceRef) + item.Detail = nullStringPtr(detail) + evidence = append(evidence, item) + } + return evidence, rows.Err() +} + +func (s *Store) loadCandidateEvidencePageContext( + ctx context.Context, candidates []IdentityMatchCandidate, +) error { + if len(candidates) == 0 { + return nil + } + placeholders := make([]string, len(candidates)) + args := make([]any, len(candidates)) + index := make(map[int64]int, len(candidates)) + for i := range candidates { + placeholders[i] = "?" + args[i] = candidates[i].ID + index[candidates[i].ID] = i + } + rows, err := s.db.QueryContext(ctx, `SELECT + id, candidate_id, evidence_kind, evidence_ref, detail, source, created_at + FROM identity_match_evidence WHERE candidate_id IN (`+ + strings.Join(placeholders, ",")+`) ORDER BY candidate_id, id`, args...) + if err != nil { + return fmt.Errorf("load identity match evidence: %w", err) + } + evidence, err := scanIdentityMatchEvidenceRows(rows) + if err != nil { + return fmt.Errorf("load identity match evidence: %w", err) + } + for _, item := range evidence { + i := index[item.CandidateID] + candidates[i].Evidence = append(candidates[i].Evidence, item) + } + return nil +} diff --git a/internal/store/identity_match_candidates_test.go b/internal/store/identity_match_candidates_test.go new file mode 100644 index 000000000..b3e585040 --- /dev/null +++ b/internal/store/identity_match_candidates_test.go @@ -0,0 +1,396 @@ +package store_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil/storetest" +) + +func TestUsernameOnlyCandidateCannotBeAcceptedWithoutCorroboration(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + left, err := st.EnsureParticipantByIdentifier("beeper", "@alice:example.org", "Alice Example") + require.NoError(err) + right, err := st.EnsureParticipantByIdentifier("beeper", "@bob:example.org", "Bob Example") + require.NoError(err) + candidate, created, err := st.UpsertIdentityMatchCandidateContext(ctx, store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchParticipant, LeftID: left, + RightKind: store.IdentityMatchParticipant, RightID: right, + Basis: store.IdentityMatchServiceScopeUsername, ServiceSlug: new("x"), + NormalizedValue: new("shared"), State: store.IdentityMatchStateCandidate, + Source: store.ProvenanceArchiveObservation, + }) + require.NoError(err) + assert.True(created) + for _, kind := range []string{"phone", "display_name"} { + _, err := st.AddIdentityMatchEvidenceContext(ctx, candidate.ID, store.IdentityMatchEvidenceInput{ + EvidenceKind: kind, Source: store.ProvenanceArchiveObservation, + }) + require.NoError(err) + } + _, err = st.DecideIdentityMatchCandidateContext( + ctx, candidate.ID, store.IdentityMatchStateAccepted, "system", nil, + ) + require.ErrorIs(err, store.ErrIdentityMatchNotAcceptable) + accepted, err := st.DecideIdentityMatchCandidateContext( + ctx, candidate.ID, store.IdentityMatchStateAccepted, "user", new("confirmed"), + ) + require.NoError(err) + assert.Equal(store.IdentityMatchStateAccepted, accepted.State) + assert.Len(accepted.Evidence, 2) +} + +func TestStableProviderIDCandidateMayBeAcceptedBySystem(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + left, err := st.EnsureParticipantByIdentifier("beeper", "@alice:example.org", "Alice Example") + require.NoError(err) + right, err := st.EnsureParticipantByIdentifier("beeper", "@alice2:example.org", "Alice Example") + require.NoError(err) + candidate, _, err := st.UpsertIdentityMatchCandidateContext(context.Background(), store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchParticipant, LeftID: left, + RightKind: store.IdentityMatchParticipant, RightID: right, + Basis: store.IdentityMatchStableProviderID, NormalizedValue: new("beeper-user-1"), + State: store.IdentityMatchStateCandidate, + Source: store.ProvenanceArchiveObservation, + }) + require.NoError(err) + accepted, err := st.DecideIdentityMatchCandidateContext( + context.Background(), candidate.ID, store.IdentityMatchStateAccepted, "system", nil, + ) + require.NoError(err) + assert.NotNil(accepted.DecidedAt) +} + +func TestStableProviderIDCandidateWithoutRecordedValueRequiresUserAcceptance(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + left, err := st.EnsureParticipantByIdentifier("beeper", "@alice:example.org", "Alice Example") + require.NoError(err) + right, err := st.EnsureParticipantByIdentifier("beeper", "@alice2:example.org", "Alice Example") + require.NoError(err) + candidate, _, err := st.UpsertIdentityMatchCandidateContext(ctx, store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchParticipant, LeftID: left, + RightKind: store.IdentityMatchParticipant, RightID: right, + Basis: store.IdentityMatchStableProviderID, State: store.IdentityMatchStateCandidate, + Source: store.ProvenanceArchiveObservation, + }) + require.NoError(err) + _, err = st.DecideIdentityMatchCandidateContext( + ctx, candidate.ID, store.IdentityMatchStateAccepted, "system", nil, + ) + require.ErrorIs(err, store.ErrIdentityMatchNotAcceptable, + "a stable-provider-id basis without the matched value must not be system-accepted") + accepted, err := st.DecideIdentityMatchCandidateContext( + ctx, candidate.ID, store.IdentityMatchStateAccepted, "user", nil, + ) + require.NoError(err) + assert.Equal(store.IdentityMatchStateAccepted, accepted.State) +} + +func TestUpsertIdentityMatchCandidateEnforcesServiceScope(t *testing.T) { + require := require.New(t) + st := storetest.New(t).Store + ctx := context.Background() + left, err := st.EnsureParticipantByIdentifier("example", "scope-left", "Left") + require.NoError(err) + right, err := st.EnsureParticipantByIdentifier("example", "scope-right", "Right") + require.NoError(err) + input := store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchParticipant, LeftID: left, + RightKind: store.IdentityMatchParticipant, RightID: right, + Basis: store.IdentityMatchServiceScopeUsername, ServiceSlug: new("slack"), + NormalizedValue: new("alice"), State: store.IdentityMatchStateCandidate, + Source: store.ProvenanceArchiveObservation, + } + _, _, err = st.UpsertIdentityMatchCandidateContext(ctx, input) + require.ErrorIs(err, store.ErrServiceScopeRequired, + "a required-scope service must not accept an unscoped candidate") + + input.ServiceSlug = nil + input.ScopeKind = new("workspace") + _, _, err = st.UpsertIdentityMatchCandidateContext(ctx, input) + require.ErrorIs(err, store.ErrServiceScopeIncomplete, + "a scope kind without a value must not fragment candidate keys") +} + +func TestBlankNormalizedValueDoesNotSatisfySystemAcceptance(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + left, err := st.EnsureParticipantByIdentifier("beeper", "@alice:example.org", "Alice Example") + require.NoError(err) + right, err := st.EnsureParticipantByIdentifier("beeper", "@alice2:example.org", "Alice Example") + require.NoError(err) + candidate, _, err := st.UpsertIdentityMatchCandidateContext(ctx, store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchParticipant, LeftID: left, + RightKind: store.IdentityMatchParticipant, RightID: right, + Basis: store.IdentityMatchStableProviderID, NormalizedValue: new(" "), + State: store.IdentityMatchStateCandidate, + Source: store.ProvenanceArchiveObservation, + }) + require.NoError(err) + assert.Nil(candidate.NormalizedValue, + "a blank normalized value must be stored as absent, not as an empty string") + _, err = st.DecideIdentityMatchCandidateContext( + ctx, candidate.ID, store.IdentityMatchStateAccepted, "system", nil, + ) + require.ErrorIs(err, store.ErrIdentityMatchNotAcceptable, + "a blank stable ID must not satisfy the non-user acceptance guard") +} + +func TestUpsertIdentityMatchCandidateRejectsDecisionStates(t *testing.T) { + require := require.New(t) + st := storetest.New(t).Store + ctx := context.Background() + left, err := st.EnsureParticipantByIdentifier("example", "left", "Left User") + require.NoError(err) + right, err := st.EnsureParticipantByIdentifier("example", "right", "Right User") + require.NoError(err) + + for _, state := range []store.IdentityMatchState{ + store.IdentityMatchStateAccepted, + store.IdentityMatchStateRejected, + } { + _, created, err := st.UpsertIdentityMatchCandidateContext(ctx, store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchParticipant, LeftID: left, + RightKind: store.IdentityMatchParticipant, RightID: right, + Basis: store.IdentityMatchStableProviderID, State: state, + Source: store.ProvenanceArchiveObservation, + }) + require.ErrorIs(err, store.ErrInvalidIdentityMatchState, state) + require.False(created, state) + } + + candidates, err := st.ListIdentityMatchCandidatesContext(ctx, nil, 10, 0) + require.NoError(err) + require.Empty(candidates, "decision states must not create candidates directly") +} + +func TestRejectedCandidateIsRetainedAndEndpointsCanonical(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + left, err := st.EnsureParticipantByIdentifier("beeper", "@alice:example.org", "Alice Example") + require.NoError(err) + right, err := st.EnsureParticipantByIdentifier("beeper", "@bob:example.org", "Bob Example") + require.NoError(err) + input := store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchParticipant, LeftID: left, + RightKind: store.IdentityMatchParticipant, RightID: right, + Basis: store.IdentityMatchEmail, State: store.IdentityMatchStateCandidate, + Source: store.ProvenanceArchiveObservation, + } + candidate, _, err := st.UpsertIdentityMatchCandidateContext(ctx, input) + require.NoError(err) + _, err = st.DecideIdentityMatchCandidateContext( + ctx, candidate.ID, store.IdentityMatchStateRejected, "user", nil, + ) + require.NoError(err) + input.LeftID, input.RightID = right, left + again, created, err := st.UpsertIdentityMatchCandidateContext(ctx, input) + require.NoError(err) + assert.False(created) + assert.Equal(store.IdentityMatchStateRejected, again.State) + _, _, err = st.UpsertIdentityMatchCandidateContext(ctx, store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchParticipant, LeftID: left, + RightKind: store.IdentityMatchParticipant, RightID: left, + Basis: store.IdentityMatchEmail, State: store.IdentityMatchStateCandidate, + Source: store.ProvenanceArchiveObservation, + }) + require.ErrorIs(err, store.ErrIdentityMatchSelfLink) +} + +func TestObservationConflictPromotesNeutralCandidateAndPreservesReviewProvenance(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := t.Context() + left, err := st.EnsureParticipantByIdentifier("example", "left-promotion", "Left") + require.NoError(err) + right, err := st.EnsureParticipantByIdentifier("example", "right-promotion", "Right") + require.NoError(err) + normalized := "shared@example.org" + candidate, created, err := st.UpsertIdentityMatchCandidateContext( + ctx, store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchParticipant, LeftID: left, + RightKind: store.IdentityMatchParticipant, RightID: right, + Basis: store.IdentityMatchEmail, NormalizedValue: &normalized, + State: store.IdentityMatchStateCandidate, Source: store.ProvenanceSystem, + }, + ) + require.NoError(err) + require.True(created) + note := "keep this review note" + candidate, err = st.DecideIdentityMatchCandidateContext( + ctx, candidate.ID, store.IdentityMatchStateCandidate, "user", ¬e, + ) + require.NoError(err) + require.NotNil(candidate.DecidedAt) + + input := store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressEmail, OriginalValue: normalized, + ProviderUserID: new("provider-left"), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + } + _, err = st.RecordContactObservationContext(ctx, left, input) + require.NoError(err) + input.ProviderUserID = new("provider-right") + result, err := st.RecordContactObservationContext(ctx, right, input) + require.NoError(err) + require.True(result.Conflicting) + require.NotNil(result.CandidateID) + assert.Equal(candidate.ID, *result.CandidateID) + + candidates, err := st.ListIdentityMatchCandidatesContext(ctx, nil, 10, 0) + require.NoError(err) + require.Len(candidates, 1) + promoted := candidates[0] + assert.Equal(candidate.ID, promoted.ID) + assert.Equal(store.IdentityMatchStateConflict, promoted.State) + assert.Equal(store.ProvenanceSystem, promoted.Source) + assert.Equal(candidate.DecidedBy, promoted.DecidedBy) + assert.Equal(candidate.DecidedAt, promoted.DecidedAt) + assert.Equal(candidate.Notes, promoted.Notes) +} + +func TestIdentityMatchCandidatesKeepDistinctNormalizedValues(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := t.Context() + left, err := st.EnsureParticipantByIdentifier("example", "left-value", "Left") + require.NoError(err) + right, err := st.EnsureParticipantByIdentifier("example", "right-value", "Right") + require.NoError(err) + + var ids []int64 + for _, value := range []string{"first@example.org", "second@example.org"} { + candidate, created, err := st.UpsertIdentityMatchCandidateContext( + ctx, store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchParticipant, LeftID: left, + RightKind: store.IdentityMatchParticipant, RightID: right, + Basis: store.IdentityMatchEmail, NormalizedValue: &value, + State: store.IdentityMatchStateCandidate, + Source: store.ProvenanceArchiveObservation, + }, + ) + require.NoError(err) + assert.True(created, value) + ids = append(ids, candidate.ID) + } + + assert.NotEqual(ids[0], ids[1]) + candidates, err := st.ListIdentityMatchCandidatesContext(ctx, nil, 10, 0) + require.NoError(err) + require.Len(candidates, 2) + assert.ElementsMatch( + []string{"first@example.org", "second@example.org"}, + []string{*candidates[0].NormalizedValue, *candidates[1].NormalizedValue}, + ) +} + +func TestIdentityMatchCandidateRequiresExistingEndpoints(t *testing.T) { + requirements := require.New(t) + st := storetest.New(t).Store + ctx := context.Background() + participantID, err := st.EnsureParticipantByIdentifier( + "beeper", "@candidate-owner:example.org", "Candidate Owner", + ) + requirements.NoError(err) + person, _, err := st.CreatePersonFromParticipant(participantID) + requirements.NoError(err) + point, err := st.AddPersonContactPointContext(ctx, person.ID, store.PersonContactPointInput{ + AddressKind: store.ContactAddressEmail, OriginalValue: "owner@example.org", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + requirements.NoError(err) + observation, err := st.RecordContactObservationContext( + ctx, participantID, store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressEmail, OriginalValue: "observed@example.org", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + }, + ) + requirements.NoError(err) + + for _, test := range []struct { + name string + kind store.IdentityMatchEndpointKind + id int64 + }{ + {name: "participant", kind: store.IdentityMatchParticipant, id: participantID}, + {name: "person", kind: store.IdentityMatchPerson, id: person.ID}, + {name: "observation", kind: store.IdentityMatchObservation, + id: observation.Observation.Envelope.ID}, + {name: "contact point", kind: store.IdentityMatchContactPoint, + id: point.Envelope.ID}, + } { + t.Run(test.name, func(t *testing.T) { + require := require.New(t) + _, created, err := st.UpsertIdentityMatchCandidateContext( + ctx, store.IdentityMatchCandidateInput{ + LeftKind: test.kind, LeftID: test.id + 1_000_000, + RightKind: store.IdentityMatchParticipant, RightID: participantID, + Basis: store.IdentityMatchDisplayName, + State: store.IdentityMatchStateCandidate, + Source: store.ProvenanceSystem, + }, + ) + require.ErrorIs(err, store.ErrIdentityMatchEndpointNotFound) + require.False(created) + }) + } +} + +func TestDeletePersonRemovesCandidatesForDeletedProfileEndpoints(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + owner, err := st.EnsureParticipantByIdentifier("example", "owner", "Owner") + require.NoError(err) + other, err := st.EnsureParticipantByIdentifier("example", "other", "Other") + require.NoError(err) + person, _, err := st.CreatePersonFromParticipant(owner) + require.NoError(err) + point, err := st.AddPersonContactPointContext(ctx, person.ID, store.PersonContactPointInput{ + AddressKind: store.ContactAddressEmail, OriginalValue: "owner@example.org", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(err) + + for _, endpoint := range []struct { + kind store.IdentityMatchEndpointKind + id int64 + }{ + {kind: store.IdentityMatchPerson, id: person.ID}, + {kind: store.IdentityMatchContactPoint, id: point.Envelope.ID}, + } { + _, _, err = st.UpsertIdentityMatchCandidateContext(ctx, store.IdentityMatchCandidateInput{ + LeftKind: endpoint.kind, LeftID: endpoint.id, + RightKind: store.IdentityMatchParticipant, RightID: other, + Basis: store.IdentityMatchDisplayName, + State: store.IdentityMatchStateCandidate, + Source: store.ProvenanceSystem, + }) + require.NoError(err) + } + + person, err = st.GetPersonContext(ctx, person.ID) + require.NoError(err) + require.NoError(st.DeletePersonContext(ctx, person.ID, person.Revision)) + var remaining int + require.NoError(st.DB().QueryRow(`SELECT COUNT(*) FROM identity_match_candidates`).Scan(&remaining)) + assert.Zero(remaining, "deleted person and contact-point endpoints must not dangle") +} diff --git a/internal/store/identity_match_merge_test.go b/internal/store/identity_match_merge_test.go new file mode 100644 index 000000000..5f189455f --- /dev/null +++ b/internal/store/identity_match_merge_test.go @@ -0,0 +1,461 @@ +package store_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil/storetest" +) + +func createParticipantMatchCandidate( + t *testing.T, + st *store.Store, + leftID, rightID int64, + confidence float64, +) *store.IdentityMatchCandidate { + t.Helper() + candidate, created, err := st.UpsertIdentityMatchCandidateContext( + t.Context(), store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchParticipant, + LeftID: leftID, + RightKind: store.IdentityMatchParticipant, + RightID: rightID, + Basis: store.IdentityMatchDisplayName, + State: store.IdentityMatchStateCandidate, + Confidence: &confidence, + Source: store.ProvenanceSystem, + }, + ) + require.NoError(t, err) + require.True(t, created) + return candidate +} + +func addMergeEvidence( + t *testing.T, st *store.Store, candidateID int64, reference string, +) { + t.Helper() + _, err := st.AddIdentityMatchEvidenceContext( + t.Context(), candidateID, store.IdentityMatchEvidenceInput{ + EvidenceKind: "synthetic_merge_evidence", + EvidenceRef: &reference, + Source: store.ProvenanceSystem, + }, + ) + require.NoError(t, err) +} + +func TestMergeParticipantsRewritesCandidatesAndDropsSelfLinks(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + st := f.Store + absorbed := f.EnsureParticipant("absorbed@example.com", "Absorbed", "example.com") + survivor := f.EnsureParticipant("survivor@example.com", "Survivor", "example.com") + third := f.EnsureParticipant("third@example.com", "Third", "example.com") + + rewritten := createParticipantMatchCandidate(t, st, absorbed, third, 0.55) + createParticipantMatchCandidate(t, st, absorbed, survivor, 0.45) + + require.NoError(st.MergeParticipants(absorbed, survivor)) + + candidates, err := st.ListIdentityMatchCandidatesContext( + t.Context(), nil, 100, 0, + ) + require.NoError(err) + require.Len(candidates, 1, "the merge-created self-link must be removed") + assert.Equal(rewritten.ID, candidates[0].ID) + assert.ElementsMatch( + []int64{survivor, third}, + []int64{candidates[0].LeftID, candidates[0].RightID}, + ) +} + +func TestMergeParticipantsCollapsesCandidateEvidenceAndDecision(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + st := f.Store + absorbed := f.EnsureParticipant("absorbed@example.com", "Absorbed", "example.com") + survivor := f.EnsureParticipant("survivor@example.com", "Survivor", "example.com") + third := f.EnsureParticipant("third@example.com", "Third", "example.com") + + absorbedCandidate := createParticipantMatchCandidate(t, st, absorbed, third, 0.85) + survivorCandidate := createParticipantMatchCandidate(t, st, survivor, third, 0.40) + absorbedNote := "accepted before participant merge" + accepted, err := st.DecideIdentityMatchCandidateContext( + t.Context(), absorbedCandidate.ID, store.IdentityMatchStateAccepted, + "user", &absorbedNote, + ) + require.NoError(err) + addMergeEvidence(t, st, absorbedCandidate.ID, "absorbed-evidence") + addMergeEvidence(t, st, survivorCandidate.ID, "survivor-evidence") + + require.NoError(st.MergeParticipants(absorbed, survivor)) + + candidates, err := st.ListIdentityMatchCandidatesContext( + t.Context(), nil, 100, 0, + ) + require.NoError(err) + require.Len(candidates, 1) + merged := candidates[0] + assert.Equal(absorbedCandidate.ID, merged.ID, "the lower stable ID must survive") + assert.Equal(store.IdentityMatchStateAccepted, merged.State) + assert.Equal(accepted.DecidedBy, merged.DecidedBy) + assert.Equal(accepted.Notes, merged.Notes) + require.NotNil(merged.Confidence) + assert.InDelta(0.85, *merged.Confidence, 0) + require.Len(merged.Evidence, 2) + assert.ElementsMatch( + []string{"absorbed-evidence", "survivor-evidence"}, + []string{*merged.Evidence[0].EvidenceRef, *merged.Evidence[1].EvidenceRef}, + ) +} + +func TestMergeParticipantsMarksOpposingCandidateDecisionsConflict(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + st := f.Store + absorbed := f.EnsureParticipant("absorbed@example.com", "Absorbed", "example.com") + survivor := f.EnsureParticipant("survivor@example.com", "Survivor", "example.com") + third := f.EnsureParticipant("third@example.com", "Third", "example.com") + + absorbedCandidate := createParticipantMatchCandidate(t, st, absorbed, third, 0.60) + survivorCandidate := createParticipantMatchCandidate(t, st, survivor, third, 0.70) + _, err := st.DecideIdentityMatchCandidateContext( + t.Context(), absorbedCandidate.ID, store.IdentityMatchStateAccepted, + "user", nil, + ) + require.NoError(err) + _, err = st.DecideIdentityMatchCandidateContext( + t.Context(), survivorCandidate.ID, store.IdentityMatchStateRejected, + "user", nil, + ) + require.NoError(err) + + require.NoError(st.MergeParticipants(absorbed, survivor)) + + candidates, err := st.ListIdentityMatchCandidatesContext( + t.Context(), nil, 100, 0, + ) + require.NoError(err) + require.Len(candidates, 1) + assert.Equal(store.IdentityMatchStateConflict, candidates[0].State) + require.NotNil(candidates[0].DecidedBy) + assert.Equal("system", *candidates[0].DecidedBy) + require.NotNil(candidates[0].DecidedAt) +} + +func TestMergeParticipantsCarriesConfidenceProvenanceFromDuplicate(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + st := f.Store + absorbed := f.EnsureParticipant("absorbed@example.com", "Absorbed", "example.com") + survivor := f.EnsureParticipant("survivor@example.com", "Survivor", "example.com") + third := f.EnsureParticipant("third@example.com", "Third", "example.com") + + userCandidate, created, err := st.UpsertIdentityMatchCandidateContext( + t.Context(), store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchParticipant, LeftID: absorbed, + RightKind: store.IdentityMatchParticipant, RightID: third, + Basis: store.IdentityMatchDisplayName, State: store.IdentityMatchStateCandidate, + Source: store.ProvenanceUser, + }, + ) + require.NoError(err) + require.True(created) + confidence := 0.90 + _, created, err = st.UpsertIdentityMatchCandidateContext( + t.Context(), store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchParticipant, LeftID: survivor, + RightKind: store.IdentityMatchParticipant, RightID: third, + Basis: store.IdentityMatchDisplayName, State: store.IdentityMatchStateCandidate, + Confidence: &confidence, Source: store.ProvenanceSystem, + }, + ) + require.NoError(err) + require.True(created) + + require.NoError(st.MergeParticipants(absorbed, survivor)) + candidates, err := st.ListIdentityMatchCandidatesContext(t.Context(), nil, 100, 0) + require.NoError(err) + require.Len(candidates, 1) + assert.Equal(userCandidate.ID, candidates[0].ID) + assert.Equal(store.ProvenanceSystem, candidates[0].Source) + require.NotNil(candidates[0].Confidence) + assert.InDelta(confidence, *candidates[0].Confidence, 0) +} + +func TestMergeParticipantsPreservesPromotedObservationConflictOrigin(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + st := f.Store + ctx := t.Context() + absorbed := f.EnsureParticipant("promoted-absorbed@example.org", "Promoted Absorbed", "example.org") + survivor := f.EnsureParticipant("generated-survivor@example.org", "Generated Survivor", "example.org") + third := f.EnsureParticipant("merge-third@example.org", "Merge Third", "example.org") + normalized := "merge-shared@example.org" + + input := store.ParticipantContactObservationInput{ + SourceID: &f.Source.ID, AddressKind: store.ContactAddressEmail, + OriginalValue: normalized, + Envelope: store.ValueEnvelopeInput{ + Source: store.ProvenanceArchiveObservation, + }, + } + input.ProviderUserID = new("generated-survivor-provider") + _, err := st.RecordContactObservationContext(ctx, survivor, input) + require.NoError(err) + input.ProviderUserID = new("third-provider") + _, err = st.RecordContactObservationContext(ctx, third, input) + require.NoError(err) + + confidence := 0.8 + note := "preserve promoted merge review" + promoted, created, err := st.UpsertIdentityMatchCandidateContext( + ctx, store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchParticipant, LeftID: absorbed, + RightKind: store.IdentityMatchParticipant, RightID: third, + Basis: store.IdentityMatchEmail, NormalizedValue: &normalized, + State: store.IdentityMatchStateCandidate, Confidence: &confidence, + Source: store.ProvenanceSystem, Notes: ¬e, + }, + ) + require.NoError(err) + require.True(created) + addMergeEvidence(t, st, promoted.ID, "promoted-merge-evidence") + input.ProviderUserID = new("promoted-absorbed-provider") + _, err = st.RecordContactObservationContext(ctx, absorbed, input) + require.NoError(err) + + require.NoError(st.MergeParticipants(absorbed, survivor)) + require.NoError(st.RemoveSource(f.Source.ID)) + candidates, err := st.ListIdentityMatchCandidatesContext(ctx, nil, 100, 0) + require.NoError(err) + require.Len(candidates, 1) + assert.Equal(store.IdentityMatchStateCandidate, candidates[0].State) + assert.Equal(store.ProvenanceSystem, candidates[0].Source) + assert.Equal(¬e, candidates[0].Notes) + require.Len(candidates[0].Evidence, 1) + assert.Equal("promoted-merge-evidence", *candidates[0].Evidence[0].EvidenceRef) +} + +func TestMergeParticipantsPreservesDecisionMetadataWhenConflictWins(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + st := f.Store + ctx := t.Context() + absorbed := f.EnsureParticipant("decided-absorbed@example.org", "Decided Absorbed", "example.org") + survivor := f.EnsureParticipant("conflict-survivor@example.org", "Conflict Survivor", "example.org") + third := f.EnsureParticipant("decided-third@example.org", "Decided Third", "example.org") + normalized := "decision-shared@example.org" + + candidate, created, err := st.UpsertIdentityMatchCandidateContext( + ctx, store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchParticipant, LeftID: absorbed, + RightKind: store.IdentityMatchParticipant, RightID: third, + Basis: store.IdentityMatchEmail, NormalizedValue: &normalized, + State: store.IdentityMatchStateCandidate, Source: store.ProvenanceUser, + }, + ) + require.NoError(err) + require.True(created) + note := "accepted after manual review" + accepted, err := st.DecideIdentityMatchCandidateContext( + ctx, candidate.ID, store.IdentityMatchStateAccepted, "user", ¬e, + ) + require.NoError(err) + + input := store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressEmail, OriginalValue: normalized, + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + } + input.ProviderUserID = new("survivor-provider") + _, err = st.RecordContactObservationContext(ctx, survivor, input) + require.NoError(err) + input.ProviderUserID = new("third-provider") + conflicting, err := st.RecordContactObservationContext(ctx, third, input) + require.NoError(err) + require.True(conflicting.Conflicting) + + require.NoError(st.MergeParticipants(absorbed, survivor)) + + candidates, err := st.ListIdentityMatchCandidatesContext(ctx, nil, 100, 0) + require.NoError(err) + require.Len(candidates, 1) + merged := candidates[0] + assert.Equal(store.IdentityMatchStateConflict, merged.State) + require.NotNil(merged.DecidedBy, "the user decision must survive the conflict collapse") + assert.Equal("user", *merged.DecidedBy) + assert.Equal(accepted.DecidedAt, merged.DecidedAt) + require.NotNil(merged.Notes) + assert.Equal(note, *merged.Notes) + + // Once the observation pair no longer conflicts, cleanup must restore + // the accepted decision rather than demote to an undecided candidate. + observations, err := st.ListParticipantObservationsContext(ctx, survivor, true) + require.NoError(err) + require.Len(observations, 1) + require.NoError(st.SupersedeParticipantObservationContext( + ctx, survivor, observations[0].Envelope.ID, nil, + )) + candidates, err = st.ListIdentityMatchCandidatesContext(ctx, nil, 100, 0) + require.NoError(err) + require.Len(candidates, 1) + restored := candidates[0] + assert.Equal(store.IdentityMatchStateAccepted, restored.State, + "cleanup must restore the pre-conflict accepted decision") + require.NotNil(restored.DecidedBy) + assert.Equal("user", *restored.DecidedBy) + require.NotNil(restored.Notes) + assert.Equal(note, *restored.Notes) +} + +func TestMergeParticipantsPreservesManualConflictAfterObservationCleanup(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + st := f.Store + ctx := t.Context() + absorbed := f.EnsureParticipant("manual-absorbed@example.org", "Manual Absorbed", "example.org") + survivor := f.EnsureParticipant("generated-survivor@example.org", "Generated Survivor", "example.org") + third := f.EnsureParticipant("manual-third@example.org", "Manual Third", "example.org") + normalized := "manual-shared@example.org" + note := "manual conflict must remain reviewable" + + manual, created, err := st.UpsertIdentityMatchCandidateContext( + ctx, store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchParticipant, LeftID: absorbed, + RightKind: store.IdentityMatchParticipant, RightID: third, + Basis: store.IdentityMatchEmail, NormalizedValue: &normalized, + State: store.IdentityMatchStateConflict, Source: store.ProvenanceUser, + Notes: ¬e, + }, + ) + require.NoError(err) + require.True(created) + + input := store.ParticipantContactObservationInput{ + SourceID: &f.Source.ID, AddressKind: store.ContactAddressEmail, + OriginalValue: normalized, + Envelope: store.ValueEnvelopeInput{ + Source: store.ProvenanceArchiveObservation, + }, + } + input.ProviderUserID = new("generated-survivor-provider") + _, err = st.RecordContactObservationContext(ctx, survivor, input) + require.NoError(err) + input.ProviderUserID = new("generated-third-provider") + result, err := st.RecordContactObservationContext(ctx, third, input) + require.NoError(err) + require.True(result.Conflicting) + + require.NoError(st.MergeParticipants(absorbed, survivor)) + require.NoError(st.RemoveSource(f.Source.ID)) + candidates, err := st.ListIdentityMatchCandidatesContext(ctx, nil, 100, 0) + require.NoError(err) + require.Len(candidates, 1) + assert.Equal(manual.ID, candidates[0].ID) + assert.Equal(store.IdentityMatchStateConflict, candidates[0].State) + assert.Equal(store.ProvenanceUser, candidates[0].Source) + assert.Equal(¬e, candidates[0].Notes) +} + +func TestMergeParticipantsKeepsCandidatesForDistinctNormalizedValues(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + st := f.Store + absorbed := f.EnsureParticipant("absorbed@example.com", "Absorbed", "example.com") + survivor := f.EnsureParticipant("survivor@example.com", "Survivor", "example.com") + third := f.EnsureParticipant("third@example.com", "Third", "example.com") + + for participantID, value := range map[int64]string{ + absorbed: "first@example.org", + survivor: "second@example.org", + } { + candidate, created, err := st.UpsertIdentityMatchCandidateContext( + t.Context(), store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchParticipant, LeftID: participantID, + RightKind: store.IdentityMatchParticipant, RightID: third, + Basis: store.IdentityMatchEmail, NormalizedValue: &value, + State: store.IdentityMatchStateCandidate, + Source: store.ProvenanceArchiveObservation, + }, + ) + require.NoError(err) + require.True(created) + require.NotZero(candidate.ID) + } + + require.NoError(st.MergeParticipants(absorbed, survivor)) + candidates, err := st.ListIdentityMatchCandidatesContext(t.Context(), nil, 100, 0) + require.NoError(err) + require.Len(candidates, 2) + assert.ElementsMatch( + []string{"first@example.org", "second@example.org"}, + []string{*candidates[0].NormalizedValue, *candidates[1].NormalizedValue}, + ) +} + +func TestMergeParticipantsRollsBackWhenCandidateRewriteFails(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + st := f.Store + absorbed := f.EnsureParticipant("absorbed@example.com", "Absorbed", "example.com") + survivor := f.EnsureParticipant("survivor@example.com", "Survivor", "example.com") + third := f.EnsureParticipant("third@example.com", "Third", "example.com") + createParticipantMatchCandidate(t, st, absorbed, third, 0.50) + + if st.IsPostgreSQL() { + _, err := st.DB().ExecContext(context.Background(), ` + CREATE FUNCTION fail_candidate_merge() RETURNS trigger AS $$ + BEGIN + RAISE EXCEPTION 'forced candidate merge failure'; + END; + $$ LANGUAGE plpgsql; + CREATE TRIGGER fail_candidate_merge + BEFORE UPDATE OF left_id, right_id ON identity_match_candidates + FOR EACH ROW EXECUTE FUNCTION fail_candidate_merge(); + `) + require.NoError(err) + } else { + _, err := st.DB().ExecContext(context.Background(), ` + CREATE TRIGGER fail_candidate_merge + BEFORE UPDATE OF left_id, right_id ON identity_match_candidates + BEGIN + SELECT RAISE(ABORT, 'forced candidate merge failure'); + END; + `) + require.NoError(err) + } + + err := st.MergeParticipants(absorbed, survivor) + require.Error(err) + assert.Contains(err.Error(), "forced candidate merge failure") + + var participantCount int + require.NoError(st.DB().QueryRow(st.Rebind( + `SELECT COUNT(*) FROM participants WHERE id = ?`), absorbed, + ).Scan(&participantCount)) + assert.Equal(1, participantCount, "the absorbed participant delete must roll back") + + candidates, listErr := st.ListIdentityMatchCandidatesContext( + t.Context(), nil, 100, 0, + ) + require.NoError(listErr) + require.Len(candidates, 1) + assert.ElementsMatch( + []int64{absorbed, third}, + []int64{candidates[0].LeftID, candidates[0].RightID}, + "candidate endpoints must roll back", + ) +} diff --git a/internal/store/messages.go b/internal/store/messages.go index 40d886a73..f971cf5f4 100644 --- a/internal/store/messages.go +++ b/internal/store/messages.go @@ -12,6 +12,7 @@ import ( "log/slog" "math/rand" "regexp" + "sort" "strings" "time" @@ -1160,8 +1161,14 @@ func (s *Store) persistMessageWithParticipantsContext( ) (int64, error) { var messageID int64 err := s.withTxContext(ctx, func(tx *loggedTx) error { + if len(participants) > 1 { + if err := s.lockParticipantDirectoryMutationTxContext(ctx, tx); err != nil { + return err + } + } q := boundQuerier{ctx: ctx, q: tx} participantIDs := make([]int64, len(participants)) + participantInserted := false for idx, participant := range participants { if err := ctx.Err(); err != nil { return err @@ -1172,12 +1179,21 @@ func (s *Store) persistMessageWithParticipantsContext( participant.EmailAddress, participant.DisplayName, participant.Domain, + func() error { + participantInserted = true + return nil + }, ) if err != nil { return fmt.Errorf("ensure participant %d: %w", idx, err) } participantIDs[idx] = participantID } + if participantInserted { + if err := s.bumpParticipantDisplayNameRevisionContext(ctx, tx); err != nil { + return err + } + } if err := ctx.Err(); err != nil { return err @@ -1289,12 +1305,12 @@ type Participant struct { } // EnsureParticipant gets or creates a participant by email. Atomic via -// INSERT … ON CONFLICT … RETURNING id so two goroutines (or two -// processes against PostgreSQL) cannot race between a SELECT-empty and -// the follow-up INSERT and both succeed — one would otherwise lose to -// the unique constraint on (email_address) with a 23505 error. Display -// name and domain are left untouched on conflict to preserve any -// hand-edited values. +// INSERT … ON CONFLICT … DO NOTHING followed by an in-transaction lookup, +// so two goroutines (or two processes against PostgreSQL) cannot race +// between a SELECT-empty and the follow-up INSERT and both succeed — one +// would otherwise lose to the unique constraint on (email_address) with a +// 23505 error. Display name and domain are left untouched on conflict to +// preserve any hand-edited values. func (s *Store) EnsureParticipant(email, displayName, domain string) (int64, error) { return s.EnsureParticipantContext(context.Background(), email, displayName, domain) } @@ -1306,13 +1322,25 @@ func (s *Store) EnsureParticipantContext( displayName, domain string, ) (int64, error) { - return ensureParticipantWith( - boundQuerier{ctx: ctx, q: s.db}, - s.dialect, - email, - displayName, - domain, - ) + var id int64 + err := s.withTxContext(ctx, func(tx *loggedTx) error { + var err error + id, err = ensureParticipantWith( + boundQuerier{ctx: ctx, q: tx}, + s.dialect, + email, + displayName, + domain, + func() error { + return s.bumpParticipantDisplayNameRevisionContext(ctx, tx) + }, + ) + return err + }) + if err != nil { + return 0, err + } + return id, nil } func ensureParticipantWith( @@ -1321,25 +1349,49 @@ func ensureParticipantWith( email, displayName, domain string, + onInsert func() error, ) (int64, error) { // ON CONFLICT must mirror the partial unique index on // participants(email_address) WHERE email_address IS NOT NULL — both // PG and SQLite require the WHERE clause on the conflict target to - // match the partial index exactly. DO UPDATE (no-op assignment on - // the same column) makes RETURNING fire for both INSERT and the - // existing-row case, giving us the id either way. - var id int64 - err := q.QueryRow(fmt.Sprintf(` - INSERT INTO participants (email_address, display_name, domain, created_at, updated_at) - VALUES (?, ?, ?, %s, %s) - ON CONFLICT (email_address) WHERE email_address IS NOT NULL - DO UPDATE SET email_address = EXCLUDED.email_address - RETURNING id - `, dialect.Now(), dialect.Now()), email, displayName, domain).Scan(&id) - if err != nil { - return 0, err + // match the partial index exactly. INSERT ... DO NOTHING lets us use + // RowsAffected to distinguish an actual insert from an idempotent retry. + for range 3 { + result, err := q.Exec(fmt.Sprintf(` + INSERT INTO participants (email_address, display_name, domain, created_at, updated_at) + VALUES (?, ?, ?, %s, %s) + ON CONFLICT (email_address) WHERE email_address IS NOT NULL + DO NOTHING + `, dialect.Now(), dialect.Now()), email, displayName, domain) + if err != nil { + return 0, err + } + inserted, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("check participant insert: %w", err) + } + if inserted > 0 && onInsert != nil { + if err := onInsert(); err != nil { + return 0, err + } + } + var id int64 + err = q.QueryRow( + `SELECT id FROM participants WHERE email_address = ?`+dialect.SelectForUpdate(), + email, + ).Scan(&id) + if err == nil { + return id, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return 0, err + } + // PostgreSQL does not retain a row lock after ON CONFLICT DO NOTHING. + // A concurrent participant merge can therefore delete the conflicting + // row before the SELECT. Retry so the ensure recreates the row instead + // of leaking that transient gap to callers. } - return id, nil + return 0, fmt.Errorf("ensure participant %q after concurrent deletion", email) } // EnsureParticipantsBatch gets or creates participants in batch. @@ -1350,42 +1402,50 @@ func (s *Store) EnsureParticipantsBatch(addresses []mime.Address) (map[string]in } result := make(map[string]int64) - - // First, try to insert all (ignoring conflicts) - insertSQL := s.dialect.InsertOrIgnore(fmt.Sprintf(`INSERT OR IGNORE INTO participants (email_address, display_name, domain, created_at, updated_at) - VALUES (?, ?, ?, %s, %s)`, s.dialect.Now(), s.dialect.Now())) + unique := make(map[string]mime.Address, len(addresses)) for _, addr := range addresses { if addr.Email == "" { continue } - if _, err := s.db.Exec(insertSQL, addr.Email, addr.Name, addr.Domain); err != nil { - return nil, err - } - } - - // Then fetch all IDs - emails := make([]string, 0, len(addresses)) - for _, addr := range addresses { - if addr.Email != "" { - emails = append(emails, addr.Email) + if _, exists := unique[addr.Email]; !exists { + unique[addr.Email] = addr } } - - if len(emails) == 0 { + if len(unique) == 0 { return result, nil } + emails := make([]string, 0, len(unique)) + for email := range unique { + emails = append(emails, email) + } + sort.Strings(emails) - err := queryInChunks(s.db, emails, nil, - `SELECT email_address, id FROM participants WHERE email_address IN (%s)`, - func(rows *loggedRows) error { - var email string - var id int64 - if err := rows.Scan(&email, &id); err != nil { + err := s.withTx(func(tx *loggedTx) error { + if err := s.lockParticipantDirectoryMutationTxContext( + context.Background(), tx, + ); err != nil { + return err + } + inserted := false + for _, email := range emails { + addr := unique[email] + id, err := ensureParticipantWith( + tx, s.dialect, addr.Email, addr.Name, addr.Domain, + func() error { + inserted = true + return nil + }, + ) + if err != nil { return err } result[email] = id - return nil - }) + } + if inserted { + return s.bumpParticipantDisplayNameRevision(tx) + } + return nil + }) if err != nil { return nil, err } @@ -2704,38 +2764,119 @@ func (s *Store) EnsureParticipantByPhone(phone, displayName, identifierType stri return 0, fmt.Errorf("phone number must be in E.164 format (starting with +), got %q", phone) } - // Atomic upsert via ON CONFLICT — see EnsureParticipant for the - // SELECT-then-INSERT race this collapses. The conflict target - // mirrors the partial unique index on participants(phone_number) - // WHERE phone_number IS NOT NULL exactly, which is required by - // both PG and SQLite for partial-index ON CONFLICT to bind. The - // DO UPDATE backfills display_name when the existing row has none, - // preserving the prior best-effort behaviour without a second - // round-trip. - now := s.dialect.Now() + // The conflict target mirrors the partial unique index on + // participants(phone_number) WHERE phone_number IS NOT NULL exactly, + // which is required by both PG and SQLite for partial-index ON CONFLICT + // to bind. INSERT ... DO NOTHING lets the actual insert be distinguished + // from an existing participant; a guarded UPDATE then reports whether an + // existing blank display name was really filled. var id int64 - err := s.db.QueryRow(fmt.Sprintf(` - INSERT INTO participants (phone_number, display_name, created_at, updated_at) - VALUES (?, ?, %s, %s) - ON CONFLICT (phone_number) WHERE phone_number IS NOT NULL - DO UPDATE SET display_name = CASE - WHEN COALESCE(NULLIF(TRIM(participants.display_name), ''), '') = '' - AND EXCLUDED.display_name != '' - THEN EXCLUDED.display_name - ELSE participants.display_name - END - RETURNING id - `, now, now), phone, displayName).Scan(&id) - if err != nil { - return 0, fmt.Errorf("upsert participant by phone: %w", err) - } + err := s.withTx(func(tx *loggedTx) error { + now := s.dialect.Now() + for range 3 { + insertResult, err := tx.Exec(fmt.Sprintf(` + INSERT INTO participants (phone_number, display_name, created_at, updated_at) + VALUES (?, ?, %s, %s) + ON CONFLICT (phone_number) WHERE phone_number IS NOT NULL + DO NOTHING + `, now, now), phone, displayName) + if err != nil { + return fmt.Errorf("insert participant by phone: %w", err) + } + inserted, err := insertResult.RowsAffected() + if err != nil { + return fmt.Errorf("check participant by phone insert: %w", err) + } + if inserted > 0 { + if err := s.bumpParticipantDisplayNameRevision(tx); err != nil { + return err + } + } + if inserted == 0 && displayName != "" { + updateResult, err := tx.Exec(` + UPDATE participants SET display_name = ? + WHERE phone_number = ? + AND COALESCE(NULLIF(TRIM(display_name), ''), '') = '' + AND ? != '' + AND (display_name IS NULL OR display_name <> ?) + `, displayName, phone, displayName, displayName) + if err != nil { + return fmt.Errorf("backfill participant by phone: %w", err) + } + if _, err := s.bumpParticipantDisplayNameRevisionIfChanged(tx, updateResult); err != nil { + return err + } + } + lookupErr := tx.QueryRow( + `SELECT id FROM participants WHERE phone_number = ?`+s.dialect.SelectForUpdate(), + phone, + ).Scan(&id) + if lookupErr == nil { + break + } + if !errors.Is(lookupErr, sql.ErrNoRows) { + return fmt.Errorf("lookup participant by phone: %w", lookupErr) + } + } + if id == 0 { + return fmt.Errorf("ensure participant by phone %q after concurrent deletion", phone) + } - // Ensure a participant_identifiers row exists for this identifierType. - // INSERT OR IGNORE is idempotent: a second call with the same type is a no-op. - _, err = s.db.Exec(s.dialect.InsertOrIgnore(`INSERT OR IGNORE INTO participant_identifiers (participant_id, identifier_type, identifier_value, is_primary) - VALUES (?, ?, ?, TRUE)`), id, identifierType, phone) + // Ensure a participant_identifiers row exists for this identifierType + // and attach service/scope metadata whenever the importer namespace is + // unambiguous. A repeat call repairs metadata but does not repoint the + // identifier away from its existing participant. + classificationColumns, err := s.participantIdentifierClassificationColumnsTx(tx) + if err != nil { + return err + } + if !classificationColumns { + result, err := tx.Exec(`INSERT INTO participant_identifiers ( + participant_id, identifier_type, identifier_value, is_primary + ) VALUES (?, ?, ?, TRUE) + ON CONFLICT (identifier_type, identifier_value) DO NOTHING`, + id, identifierType, phone) + if err != nil { + return fmt.Errorf("insert participant identifier: %w", err) + } + return s.bumpParticipantIdentifierRevisionIfChanged(tx, result) + } + serviceSlug, scopeKind, scopeValue := participantIdentifierClassificationValues( + identifierType, phone, + ) + result, err := tx.Exec(`INSERT INTO participant_identifiers ( + participant_id, identifier_type, identifier_value, is_primary, + service_id, scope_kind, scope_value + ) VALUES (?, ?, ?, TRUE, + (SELECT id FROM communication_services WHERE slug = ?), ?, ?) + ON CONFLICT (identifier_type, identifier_value) DO UPDATE SET + service_id = COALESCE(excluded.service_id, participant_identifiers.service_id), + scope_kind = CASE WHEN excluded.service_id IS NOT NULL + THEN excluded.scope_kind ELSE participant_identifiers.scope_kind END, + scope_value = CASE WHEN excluded.service_id IS NOT NULL + THEN excluded.scope_value ELSE participant_identifiers.scope_value END + WHERE excluded.service_id IS NOT NULL AND ( + participant_identifiers.service_id IS NULL OR + participant_identifiers.service_id <> excluded.service_id OR + (participant_identifiers.scope_kind IS NULL AND + excluded.scope_kind IS NOT NULL) OR + (participant_identifiers.scope_kind IS NOT NULL AND + excluded.scope_kind IS NULL) OR + participant_identifiers.scope_kind <> excluded.scope_kind OR + (participant_identifiers.scope_value IS NULL AND + excluded.scope_value IS NOT NULL) OR + (participant_identifiers.scope_value IS NOT NULL AND + excluded.scope_value IS NULL) OR + participant_identifiers.scope_value <> excluded.scope_value + )`, + id, identifierType, phone, serviceSlug, scopeKind, scopeValue) + if err != nil { + return fmt.Errorf("insert participant identifier: %w", err) + } + return s.bumpParticipantIdentifierRevisionIfChanged(tx, result) + }) if err != nil { - return 0, fmt.Errorf("insert participant identifier: %w", err) + return 0, err } return id, nil @@ -2756,6 +2897,16 @@ func (s *Store) MergeParticipants(oldID, newID int64) error { if err := s.lockIdentityMutationTx(tx); err != nil { return err } + if err := s.lockParticipantDirectoryMutationTxContext( + context.Background(), tx, + ); err != nil { + return err + } + if err := s.lockParticipantObservationMergeTx( + context.Background(), tx, oldID, newID, + ); err != nil { + return err + } if err := s.verifyParticipantsExistTx(tx, oldID, newID); err != nil { return err } @@ -2847,6 +2998,21 @@ func (s *Store) MergeParticipants(oldID, newID int64) error { if _, err := tx.Exec(`UPDATE participant_identifiers SET participant_id = ? WHERE participant_id = ?`, newID, oldID); err != nil { return err } + if err := s.rewriteObservationsForMergeTx( + context.Background(), tx, oldID, newID, + ); err != nil { + return err + } + if err := s.rewriteIdentityMatchCandidatesForMergeTx( + context.Background(), tx, oldID, newID, + ); err != nil { + return err + } + if err := s.deleteUnsupportedObservationIdentityConflictsContext( + context.Background(), tx, + ); err != nil { + return err + } // Sender and identifier repoints can add or remove identity evidence. // Repair the primary-store provenance before committing the merge. if err := refreshParticipantMessageAttributionContext( @@ -2874,6 +3040,9 @@ func (s *Store) MergeParticipants(oldID, newID int64) error { if err := s.bumpAccountIdentityRevision(tx); err != nil { return err } + if err := s.bumpParticipantIdentifierRevision(tx); err != nil { + return err + } _, err = tx.Exec(`DELETE FROM participants WHERE id = ?`, oldID) return err }) @@ -2942,12 +3111,44 @@ func (s *Store) SetParticipantIdentifier(participantID int64, identifierType, id if err != nil || (exists && existingParticipantID == participantID) { return err } - if _, err := tx.Exec(` - INSERT INTO participant_identifiers (participant_id, identifier_type, identifier_value, is_primary) - VALUES (?, ?, ?, FALSE) - ON CONFLICT (identifier_type, identifier_value) DO UPDATE SET participant_id = excluded.participant_id - `, participantID, identifierType, identifierValue); err != nil { - return fmt.Errorf("set participant identifier: %w", err) + serviceSlug, scopeKind, scopeValue := participantIdentifierClassificationValues( + identifierType, identifierValue, + ) + classificationColumns, err := s.participantIdentifierClassificationColumnsTx(tx) + if err != nil { + return err + } + var setErr error + if classificationColumns { + _, setErr = tx.Exec(` + INSERT INTO participant_identifiers ( + participant_id, identifier_type, identifier_value, is_primary, + service_id, scope_kind, scope_value + ) VALUES (?, ?, ?, FALSE, + (SELECT id FROM communication_services WHERE slug = ?), ?, ?) + ON CONFLICT (identifier_type, identifier_value) DO UPDATE SET + participant_id = excluded.participant_id, + service_id = COALESCE(excluded.service_id, participant_identifiers.service_id), + scope_kind = CASE WHEN excluded.service_id IS NOT NULL + THEN excluded.scope_kind ELSE participant_identifiers.scope_kind END, + scope_value = CASE WHEN excluded.service_id IS NOT NULL + THEN excluded.scope_value ELSE participant_identifiers.scope_value END + `, participantID, identifierType, identifierValue, + serviceSlug, scopeKind, scopeValue) + } else { + // Cache inspection can open a legacy archive before InitSchema adds + // service metadata. Preserve that read/repair workflow; the v2 + // migration classifies this row when the schema is initialized. + _, setErr = tx.Exec(` + INSERT INTO participant_identifiers ( + participant_id, identifier_type, identifier_value, is_primary + ) VALUES (?, ?, ?, FALSE) + ON CONFLICT (identifier_type, identifier_value) DO UPDATE SET + participant_id = excluded.participant_id + `, participantID, identifierType, identifierValue) + } + if setErr != nil { + return fmt.Errorf("set participant identifier: %w", setErr) } if err := s.bumpParticipantIdentifierRevision(tx); err != nil { return err @@ -2977,6 +3178,24 @@ func (s *Store) SetParticipantIdentifier(participantID int64, identifierType, id }) } +func (s *Store) participantIdentifierClassificationColumnsTx( + tx *loggedTx, +) (bool, error) { + var count int + query := `SELECT COUNT(*) FROM pragma_table_info('participant_identifiers') + WHERE name IN ('service_id', 'scope_kind', 'scope_value')` + if s.IsPostgreSQL() { + query = `SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'participant_identifiers' + AND column_name IN ('service_id', 'scope_kind', 'scope_value')` + } + if err := tx.QueryRow(query).Scan(&count); err != nil { + return false, fmt.Errorf("inspect participant identifier classification schema: %w", err) + } + return count == 3, nil +} + // participantIdentifierTargetTx returns the participant currently owning an // identifier, if any, without taking any lock. func participantIdentifierTargetTx( @@ -3006,39 +3225,74 @@ func (s *Store) EnsureParticipantByIdentifier(identifierType, identifierValue, d } var participantID int64 - err := s.db.QueryRow(` - SELECT participant_id FROM participant_identifiers - WHERE identifier_type = ? AND identifier_value = ? - `, identifierType, identifierValue).Scan(&participantID) - if err == nil { - if displayName != "" { - _, _ = s.db.Exec(` - UPDATE participants SET display_name = ? - WHERE id = ? AND (display_name IS NULL OR display_name = '') - `, displayName, participantID) + err := s.withTx(func(tx *loggedTx) error { + err := tx.QueryRow(` + SELECT participant_id FROM participant_identifiers + WHERE identifier_type = ? AND identifier_value = ? + `, identifierType, identifierValue).Scan(&participantID) + if err == nil { + if displayName != "" { + result, err := tx.Exec(` + UPDATE participants SET display_name = ? + WHERE id = ? AND (display_name IS NULL OR display_name = '') + `, displayName, participantID) + if err != nil { + return fmt.Errorf("backfill participant display name: %w", err) + } + if _, err := s.bumpParticipantDisplayNameRevisionIfChanged(tx, result); err != nil { + return err + } + } + return nil + } + if !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("lookup participant identifier: %w", err) } - return participantID, nil - } - if !errors.Is(err, sql.ErrNoRows) { - return 0, fmt.Errorf("lookup participant identifier: %w", err) - } - now := s.dialect.Now() - err = s.db.QueryRow(fmt.Sprintf(` - INSERT INTO participants (display_name, created_at, updated_at) - VALUES (?, %s, %s) - RETURNING id - `, now, now), displayName).Scan(&participantID) - if err != nil { - return 0, fmt.Errorf("insert participant: %w", err) - } - _, err = s.db.Exec(` - INSERT INTO participant_identifiers ( - participant_id, identifier_type, identifier_value, display_value, is_primary - ) VALUES (?, ?, ?, ?, TRUE) - `, participantID, identifierType, identifierValue, identifierValue) + now := s.dialect.Now() + if err := tx.QueryRow(fmt.Sprintf(` + INSERT INTO participants (display_name, created_at, updated_at) + VALUES (?, %s, %s) + RETURNING id + `, now, now), displayName).Scan(&participantID); err != nil { + return fmt.Errorf("insert participant: %w", err) + } + if err := s.bumpParticipantDisplayNameRevision(tx); err != nil { + return err + } + classificationColumns, err := s.participantIdentifierClassificationColumnsTx(tx) + if err != nil { + return err + } + if !classificationColumns { + _, err = tx.Exec(` + INSERT INTO participant_identifiers ( + participant_id, identifier_type, identifier_value, display_value, is_primary + ) VALUES (?, ?, ?, ?, TRUE) + `, participantID, identifierType, identifierValue, identifierValue) + if err != nil { + return fmt.Errorf("insert participant identifier: %w", err) + } + } else { + serviceSlug, scopeKind, scopeValue := participantIdentifierClassificationValues( + identifierType, identifierValue, + ) + _, err = tx.Exec(` + INSERT INTO participant_identifiers ( + participant_id, identifier_type, identifier_value, display_value, + is_primary, service_id, scope_kind, scope_value + ) VALUES (?, ?, ?, ?, TRUE, + (SELECT id FROM communication_services WHERE slug = ?), ?, ?) + `, participantID, identifierType, identifierValue, identifierValue, + serviceSlug, scopeKind, scopeValue) + if err != nil { + return fmt.Errorf("insert participant identifier: %w", err) + } + } + return s.bumpParticipantIdentifierRevision(tx) + }) if err != nil { - return 0, fmt.Errorf("insert participant identifier: %w", err) + return 0, err } return participantID, nil } @@ -3052,19 +3306,22 @@ func (s *Store) UpdateParticipantDisplayNameByPhone(phone, displayName string) ( return false, nil } - result, err := s.db.Exec(fmt.Sprintf(` - UPDATE participants SET display_name = ?, updated_at = %s - WHERE phone_number = ? AND (display_name IS NULL OR display_name = '') - `, s.dialect.Now()), displayName, phone) - if err != nil { - return false, err - } - - rows, err := result.RowsAffected() + var updated bool + err := s.withTx(func(tx *loggedTx) error { + result, err := tx.Exec(fmt.Sprintf(` + UPDATE participants SET display_name = ?, updated_at = %s + WHERE phone_number = ? AND (display_name IS NULL OR display_name = '') + `, s.dialect.Now()), displayName, phone) + if err != nil { + return err + } + updated, err = s.bumpParticipantDisplayNameRevisionIfChanged(tx, result) + return err + }) if err != nil { return false, err } - return rows > 0, nil + return updated, nil } // UpdateImessageParticipantDisplayNameByPhone backfills display_name for @@ -3082,25 +3339,29 @@ func (s *Store) UpdateImessageParticipantDisplayNameByPhone(phone, displayName s return false, nil } - result, err := s.db.Exec(fmt.Sprintf(` - UPDATE participants SET display_name = ?, updated_at = %s - WHERE phone_number = ? - AND (display_name IS NULL OR display_name = '' OR display_name = phone_number) - AND EXISTS ( - SELECT 1 FROM participant_identifiers pi - WHERE pi.participant_id = participants.id - AND pi.identifier_type = 'imessage' - ) - `, s.dialect.Now()), displayName, phone) - if err != nil { - return false, err - } - - rows, err := result.RowsAffected() + var updated bool + err := s.withTx(func(tx *loggedTx) error { + result, err := tx.Exec(fmt.Sprintf(` + UPDATE participants SET display_name = ?, updated_at = %s + WHERE phone_number = ? + AND (display_name IS NULL OR display_name = '' OR display_name = phone_number) + AND (display_name IS NULL OR display_name <> ?) + AND EXISTS ( + SELECT 1 FROM participant_identifiers pi + WHERE pi.participant_id = participants.id + AND pi.identifier_type = 'imessage' + ) + `, s.dialect.Now()), displayName, phone, displayName) + if err != nil { + return err + } + updated, err = s.bumpParticipantDisplayNameRevisionIfChanged(tx, result) + return err + }) if err != nil { return false, err } - return rows > 0, nil + return updated, nil } // RetitleImessageChats refreshes generated titles on apple_messages @@ -3361,19 +3622,22 @@ func (s *Store) UpdateParticipantDisplayNameByEmail(email, displayName string) ( return false, nil } - result, err := s.db.Exec(fmt.Sprintf(` - UPDATE participants SET display_name = ?, updated_at = %s - WHERE LOWER(email_address) = LOWER(?) AND (display_name IS NULL OR display_name = '') - `, s.dialect.Now()), displayName, email) - if err != nil { - return false, err - } - - rows, err := result.RowsAffected() + var updated bool + err := s.withTx(func(tx *loggedTx) error { + result, err := tx.Exec(fmt.Sprintf(` + UPDATE participants SET display_name = ?, updated_at = %s + WHERE LOWER(email_address) = LOWER(?) AND (display_name IS NULL OR display_name = '') + `, s.dialect.Now()), displayName, email) + if err != nil { + return err + } + updated, err = s.bumpParticipantDisplayNameRevisionIfChanged(tx, result) + return err + }) if err != nil { return false, err } - return rows > 0, nil + return updated, nil } // EnsureConversationParticipant adds a participant to a conversation. diff --git a/internal/store/migrate_participant_service_scope.go b/internal/store/migrate_participant_service_scope.go new file mode 100644 index 000000000..ede4620a6 --- /dev/null +++ b/internal/store/migrate_participant_service_scope.go @@ -0,0 +1,132 @@ +package store + +import ( + "context" + "database/sql" + "fmt" +) + +const ( + migrationParticipantServiceScope = "participant_identifiers_service_scope_v1" + migrationParticipantServiceScopeV2 = "participant_identifiers_service_scope_v2" +) + +// ensureParticipantIdentifierServiceScopeIndex runs after the legacy-column +// migrations. Schema scripts run before those migrations, so an index that +// names the new columns cannot safely live in schema.sql/schema_pg.sql: on a +// legacy table CREATE TABLE IF NOT EXISTS is a no-op and the index build would +// fail before the columns could be added. +func (s *Store) ensureParticipantIdentifierServiceScopeIndex(ctx context.Context) error { + return s.runMaintenance(ctx, func(ctx context.Context, tx *loggedTx) error { + if _, err := tx.ExecContext(ctx, ` + CREATE INDEX IF NOT EXISTS idx_participant_identifiers_service_scope + ON participant_identifiers( + service_id, scope_kind, scope_value, identifier_value + ) + WHERE service_id IS NOT NULL + `); err != nil { + return fmt.Errorf("create idx_participant_identifiers_service_scope: %w", err) + } + return nil + }) +} + +func (s *Store) ensureParticipantIdentifierServiceScope(ctx context.Context) error { + v1Applied, err := s.IsMigrationAppliedContext(ctx, migrationParticipantServiceScope) + if err != nil { + return err + } + v2Applied, err := s.IsMigrationAppliedContext(ctx, migrationParticipantServiceScopeV2) + if err != nil { + return err + } + if v1Applied && v2Applied { + return nil + } + if err := s.runMaintenance(ctx, repairParticipantIdentifierServiceScope); err != nil { + return err + } + if !v1Applied { + if err := s.MarkMigrationAppliedContext(ctx, migrationParticipantServiceScope); err != nil { + return err + } + } + if !v2Applied { + return s.MarkMigrationAppliedContext(ctx, migrationParticipantServiceScopeV2) + } + return nil +} + +func repairParticipantIdentifierServiceScope( + ctx context.Context, tx *loggedTx, +) error { + rows, err := tx.QueryContext(ctx, `SELECT id, identifier_type, identifier_value + FROM participant_identifiers ORDER BY id`) + if err != nil { + return fmt.Errorf("list participant identifiers for classification: %w", err) + } + type identifier struct { + id int64 + kind string + value string + } + identifiers := make([]identifier, 0) + for rows.Next() { + var item identifier + if err := rows.Scan(&item.id, &item.kind, &item.value); err != nil { + _ = rows.Close() + return fmt.Errorf("scan participant identifier for classification: %w", err) + } + identifiers = append(identifiers, item) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return fmt.Errorf("list participant identifiers for classification: %w", err) + } + if err := rows.Close(); err != nil { + return fmt.Errorf("close participant identifier classification rows: %w", err) + } + for _, item := range identifiers { + serviceSlug, scopeKind, scopeValue := participantIdentifierClassificationValues( + item.kind, item.value, + ) + if serviceSlug == nil { + continue + } + if _, err := tx.ExecContext(ctx, `UPDATE participant_identifiers SET + service_id = (SELECT id FROM communication_services WHERE slug = ?), + scope_kind = ?, scope_value = ? WHERE id = ?`, + serviceSlug, scopeKind, scopeValue, item.id, + ); err != nil { + return fmt.Errorf("classify participant identifier %d: %w", item.id, err) + } + } + return nil +} + +func (s *Store) classifiedIdentifierServiceSlugs( + ctx context.Context, +) (map[string]string, error) { + rows, err := s.db.QueryContext(ctx, `SELECT + pi.identifier_type, pi.identifier_value, cs.slug + FROM participant_identifiers pi + LEFT JOIN communication_services cs ON cs.id = pi.service_id + ORDER BY pi.identifier_type, pi.identifier_value`) + if err != nil { + return nil, fmt.Errorf("list classified participant identifiers: %w", err) + } + defer func() { _ = rows.Close() }() + classified := make(map[string]string) + for rows.Next() { + var kind, value string + var slug sql.NullString + if err := rows.Scan(&kind, &value, &slug); err != nil { + return nil, fmt.Errorf("scan classified participant identifier: %w", err) + } + classified[kind+":"+value] = slug.String + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list classified participant identifiers: %w", err) + } + return classified, nil +} diff --git a/internal/store/migrate_participant_service_scope_backend_test.go b/internal/store/migrate_participant_service_scope_backend_test.go new file mode 100644 index 000000000..147e1cae3 --- /dev/null +++ b/internal/store/migrate_participant_service_scope_backend_test.go @@ -0,0 +1,220 @@ +package store_test + +import ( + "context" + "database/sql" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil/storetest" +) + +func rebuildLegacyParticipantIdentifiers(t *testing.T, st *store.Store) { + t.Helper() + ctx := t.Context() + if st.IsPostgreSQL() { + _, err := st.DB().ExecContext(ctx, `ALTER TABLE participant_identifiers + DROP COLUMN service_id, + DROP COLUMN scope_kind, + DROP COLUMN scope_value`) + require.NoError(t, err) + return + } + for _, statement := range []string{ + `CREATE TABLE participant_identifiers_legacy ( + id INTEGER PRIMARY KEY, + participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE, + identifier_type TEXT NOT NULL, + identifier_value TEXT NOT NULL, + display_value TEXT, + is_primary BOOLEAN DEFAULT FALSE, + UNIQUE(identifier_type, identifier_value) + )`, + `INSERT INTO participant_identifiers_legacy ( + id, participant_id, identifier_type, identifier_value, display_value, is_primary + ) SELECT id, participant_id, identifier_type, identifier_value, display_value, is_primary + FROM participant_identifiers`, + `DROP TABLE participant_identifiers`, + `ALTER TABLE participant_identifiers_legacy RENAME TO participant_identifiers`, + } { + _, err := st.DB().ExecContext(ctx, statement) + require.NoError(t, err) + } +} + +func installRejectParticipantIdentifierWrite(t *testing.T, st *store.Store) { + t.Helper() + if st.IsPostgreSQL() { + _, err := st.DB().ExecContext(t.Context(), `ALTER TABLE participant_identifiers + ADD CONSTRAINT participant_identifiers_reject_test_values + CHECK (identifier_value NOT IN ('legacy-reject', '+15550009999'))`) + require.NoError(t, err) + return + } + _, err := st.DB().ExecContext(t.Context(), `CREATE TRIGGER reject_participant_identifier_test_values + BEFORE INSERT ON participant_identifiers + WHEN NEW.identifier_value IN ('legacy-reject', '+15550009999') + BEGIN + SELECT RAISE(ABORT, 'rejected participant identifier test value'); + END`) + require.NoError(t, err) +} + +func TestParticipantIdentifiersServiceScopeLegacyTableUpgrade(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + + participantID, err := st.EnsureParticipantByIdentifier( + "imessage", "+12025550123", "Test User", + ) + require.NoError(err) + + rebuildLegacyParticipantIdentifiers(t, st) + + _, err = st.DB().ExecContext(ctx, st.Rebind( + `DELETE FROM applied_migrations WHERE name = ?`), + "participant_identifiers_service_scope_v1", + ) + require.NoError(err) + + require.NoError(st.InitSchemaContext(ctx)) + + var serviceSlug sql.NullString + err = st.DB().QueryRowContext(ctx, st.Rebind(`SELECT cs.slug + FROM participant_identifiers pi + LEFT JOIN communication_services cs ON cs.id = pi.service_id + WHERE pi.participant_id = ? AND pi.identifier_type = 'imessage'`), participantID, + ).Scan(&serviceSlug) + require.NoError(err) + assert.Equal("imessage", serviceSlug.String) + assert.True(serviceSlug.Valid) + + _, err = st.DB().ExecContext(ctx, `DELETE FROM communication_services WHERE slug = 'imessage'`) + require.NoError(err) + var serviceID sql.NullInt64 + err = st.DB().QueryRowContext(ctx, st.Rebind(`SELECT service_id + FROM participant_identifiers + WHERE participant_id = ? AND identifier_type = 'imessage'`), participantID, + ).Scan(&serviceID) + require.NoError(err) + assert.False(serviceID.Valid, "deleting the service must retain and unclassify the identifier") +} + +func TestEnsureParticipantCreationSupportsLegacyIdentifierColumns(t *testing.T) { + tests := []struct { + name string + identifierType string + identifier string + serviceSlug string + ensure func(*store.Store) (int64, error) + }{ + { + name: "phone", + identifierType: "whatsapp", + identifier: "+15550001111", + serviceSlug: "whatsapp", + ensure: func(st *store.Store) (int64, error) { + return st.EnsureParticipantByPhone( + "+15550001111", "Legacy Phone", "whatsapp", + ) + }, + }, + { + name: "generic identifier", + identifierType: "matrix", + identifier: "@legacy:example.test", + serviceSlug: "matrix", + ensure: func(st *store.Store) (int64, error) { + return st.EnsureParticipantByIdentifier( + "matrix", "@legacy:example.test", "Legacy Matrix", + ) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + rebuildLegacyParticipantIdentifiers(t, st) + + participantID, err := tt.ensure(st) + require.NoError(err) + var storedParticipantID int64 + err = st.DB().QueryRowContext(t.Context(), st.Rebind(` + SELECT participant_id FROM participant_identifiers + WHERE identifier_type = ? AND identifier_value = ? + `), tt.identifierType, tt.identifier).Scan(&storedParticipantID) + require.NoError(err) + assert.Equal(participantID, storedParticipantID) + + _, err = st.DB().ExecContext(t.Context(), st.Rebind(` + DELETE FROM applied_migrations WHERE name IN (?, ?) + `), + "participant_identifiers_service_scope_v1", + "participant_identifiers_service_scope_v2", + ) + require.NoError(err) + require.NoError(st.InitSchemaContext(t.Context())) + var serviceSlug sql.NullString + err = st.DB().QueryRowContext(t.Context(), st.Rebind(` + SELECT cs.slug + FROM participant_identifiers pi + LEFT JOIN communication_services cs ON cs.id = pi.service_id + WHERE pi.identifier_type = ? AND pi.identifier_value = ? + `), tt.identifierType, tt.identifier).Scan(&serviceSlug) + require.NoError(err) + assert.Equal(tt.serviceSlug, serviceSlug.String) + assert.True(serviceSlug.Valid) + }) + } +} + +func TestEnsureParticipantCreationRollsBackWhenIdentifierWriteFails(t *testing.T) { + tests := []struct { + name string + ensure func(*store.Store) (int64, error) + }{ + { + name: "phone", + ensure: func(st *store.Store) (int64, error) { + return st.EnsureParticipantByPhone( + "+15550009999", "Rejected Phone", "whatsapp", + ) + }, + }, + { + name: "generic identifier", + ensure: func(st *store.Store) (int64, error) { + return st.EnsureParticipantByIdentifier( + "matrix", "legacy-reject", "Rejected Matrix", + ) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + rebuildLegacyParticipantIdentifiers(t, st) + installRejectParticipantIdentifierWrite(t, st) + var before int + require.NoError(st.DB().QueryRowContext( + t.Context(), `SELECT COUNT(*) FROM participants`, + ).Scan(&before)) + + _, err := tt.ensure(st) + require.Error(err) + var after int + require.NoError(st.DB().QueryRowContext( + t.Context(), `SELECT COUNT(*) FROM participants`, + ).Scan(&after)) + assert.Equal(before, after, "failed identifier write must not leave a participant") + }) + } +} diff --git a/internal/store/migrate_participant_service_scope_test.go b/internal/store/migrate_participant_service_scope_test.go new file mode 100644 index 000000000..499cefde4 --- /dev/null +++ b/internal/store/migrate_participant_service_scope_test.go @@ -0,0 +1,132 @@ +package store + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParticipantIdentifiersServiceScopeBackfillClassifiesLegacyIdentifiers(t *testing.T) { + if IsPostgresURL(os.Getenv("MSGVAULT_TEST_DB")) { + t.Skip("SQLite file-path migration test") + } + require := require.New(t) + assert := assert.New(t) + st, err := OpenForTest(filepath.Join(t.TempDir(), "legacy.db")) + require.NoError(err) + t.Cleanup(func() { _ = st.Close() }) + require.NoError(st.InitSchema()) + for _, identifier := range []struct{ kind, value string }{ + {"email", "alice@example.com"}, + {"phone", "+12025550123"}, + {"imessage", "+12025550124"}, + {"matrix", "@alice:example.org"}, + {"example-unknown", "alice"}, + } { + _, err := st.EnsureParticipantByIdentifier( + identifier.kind, identifier.value, "Alice Example", + ) + require.NoError(err) + } + _, err = st.db.Exec(`UPDATE participant_identifiers + SET service_id = NULL, scope_kind = NULL, scope_value = NULL`) + require.NoError(err) + _, err = st.db.Exec( + `DELETE FROM applied_migrations WHERE name = ?`, migrationParticipantServiceScope, + ) + require.NoError(err) + require.NoError(st.InitSchema()) + classified, err := st.classifiedIdentifierServiceSlugs(context.Background()) + require.NoError(err) + assert.Equal("imessage", classified["imessage:+12025550124"]) + assert.Equal("matrix", classified["matrix:@alice:example.org"]) + assert.Empty(classified["email:alice@example.com"]) + assert.Empty(classified["example-unknown:alice"]) + applied, err := st.IsMigrationApplied(migrationParticipantServiceScope) + require.NoError(err) + assert.True(applied) +} + +func TestInitSchemaContext_ParticipantIdentifiersServiceScopeBackfillStopsWhenContextIsCancelled(t *testing.T) { + if IsPostgresURL(os.Getenv("MSGVAULT_TEST_DB")) { + t.Skip("SQLite file-path migration test") + } + require := require.New(t) + assert := assert.New(t) + st, err := OpenForTest(filepath.Join(t.TempDir(), "cancelled-backfill.db")) + require.NoError(err) + t.Cleanup(func() { _ = st.Close() }) + require.NoError(st.InitSchema()) + _, err = st.EnsureParticipantByIdentifier( + "imessage", "+12025550125", "Cancellation Test", + ) + require.NoError(err) + _, err = st.db.Exec(`UPDATE participant_identifiers SET service_id = NULL + WHERE identifier_type = 'imessage' AND identifier_value = '+12025550125'`) + require.NoError(err) + + _, err = st.db.Exec( + `DELETE FROM applied_migrations WHERE name IN (?, ?)`, + migrationParticipantServiceScope, migrationParticipantServiceScopeV2, + ) + require.NoError(err) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + trigger := cancelAtStatement{ + stop: "UPDATE participant_identifiers", + cancel: cancel, + } + trigger.install(st.db) + + err = st.InitSchemaContext(ctx) + + require.True(trigger.fired, "the service-scope backfill was never reached") + require.Error(err, "a cancelled backfill must stop schema initialization") + require.ErrorIs(err, context.Canceled) + assert.Contains(err.Error(), "classify participant identifier service scope", + "the cancellation must stop the backfill itself, not a later upgrade step") + applied, ledgerErr := st.IsMigrationApplied(migrationParticipantServiceScope) + require.NoError(ledgerErr) + assert.False(applied, "a cancelled backfill must remain pending for the next startup") +} + +func TestInitSchemaContext_CommunicationServiceSeedStopsWhenContextIsCancelled(t *testing.T) { + if IsPostgresURL(os.Getenv("MSGVAULT_TEST_DB")) { + t.Skip("SQLite statement interception test") + } + require := require.New(t) + assert := assert.New(t) + st, err := OpenForTest(filepath.Join(t.TempDir(), "cancelled-seed.db")) + require.NoError(err) + t.Cleanup(func() { _ = st.Close() }) + require.NoError(st.InitSchema()) + + _, err = st.db.Exec( + `DELETE FROM applied_migrations WHERE name = ?`, communicationServicesSeedV1, + ) + require.NoError(err) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + trigger := cancelAtStatement{ + stop: "INTO communication_services", + cancel: cancel, + } + trigger.install(st.db) + + err = st.InitSchemaContext(ctx) + + require.True(trigger.fired, "the communication-service seed was never reached") + require.Error(err, "a cancelled seed must stop schema initialization") + require.ErrorIs(err, context.Canceled) + assert.Contains(err.Error(), "seed communication services", + "the cancellation must stop the seed itself, not a later upgrade step") + applied, ledgerErr := st.IsMigrationApplied(communicationServicesSeedV1) + require.NoError(ledgerErr) + assert.False(applied, "a cancelled seed must remain pending for the next startup") +} diff --git a/internal/store/partialdate.go b/internal/store/partialdate.go new file mode 100644 index 000000000..839db95bf --- /dev/null +++ b/internal/store/partialdate.go @@ -0,0 +1,211 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + "strconv" + "time" +) + +// ErrInvalidPartialDate reports a malformed or calendar-invalid partial date. +var ErrInvalidPartialDate = errors.New("invalid partial date") + +// PartialDate is a calendar date with optional components in one of the six +// reduced or truncated shapes accepted by ParsePartialDate. Nil means +// unspecified, never zero. Compact vCard spellings parse to the same shape. +// Component storage is portable and indexable. +type PartialDate struct { + Year *int `json:"year,omitempty"` + Month *int `json:"month,omitempty"` + Day *int `json:"day,omitempty"` +} + +// ParsePartialDate accepts the reduced and truncated ISO forms used by vCard. +func ParsePartialDate(raw string) (PartialDate, error) { + var date PartialDate + var err error + + switch len(raw) { + case 4: + if raw[:2] == "--" { + date.Month, err = parseDateComponent(raw[2:4], 2) + } else { + date.Year, err = parseDateComponent(raw, 4) + } + case 6: + if raw[:2] != "--" { + err = ErrInvalidPartialDate + break + } + date.Month, err = parseDateComponent(raw[2:4], 2) + if err == nil { + date.Day, err = parseDateComponent(raw[4:6], 2) + } + case 7: + if raw[:2] == "--" && raw[4] == '-' { + date.Month, err = parseDateComponent(raw[2:4], 2) + if err == nil { + date.Day, err = parseDateComponent(raw[5:7], 2) + } + } else if raw[4] == '-' { + date.Year, err = parseDateComponent(raw[:4], 4) + if err == nil { + date.Month, err = parseDateComponent(raw[5:7], 2) + } + } else { + err = ErrInvalidPartialDate + } + case 8: + date.Year, err = parseDateComponent(raw[:4], 4) + if err == nil { + date.Month, err = parseDateComponent(raw[4:6], 2) + } + if err == nil { + date.Day, err = parseDateComponent(raw[6:8], 2) + } + case 10: + if raw[4] != '-' || raw[7] != '-' { + err = ErrInvalidPartialDate + break + } + date.Year, err = parseDateComponent(raw[:4], 4) + if err == nil { + date.Month, err = parseDateComponent(raw[5:7], 2) + } + if err == nil { + date.Day, err = parseDateComponent(raw[8:10], 2) + } + case 5: + if raw[:3] != "---" { + err = ErrInvalidPartialDate + break + } + date.Day, err = parseDateComponent(raw[3:5], 2) + default: + err = ErrInvalidPartialDate + } + if err != nil { + return PartialDate{}, fmt.Errorf("%w: %q", ErrInvalidPartialDate, raw) + } + if err := date.Validate(); err != nil { + return PartialDate{}, fmt.Errorf("%w: %q", err, raw) + } + return date, nil +} + +func parseDateComponent(raw string, width int) (*int, error) { + if len(raw) != width { + return nil, ErrInvalidPartialDate + } + for i := range len(raw) { + if raw[i] < '0' || raw[i] > '9' { + return nil, ErrInvalidPartialDate + } + } + value, err := strconv.Atoi(raw) + if err != nil { + return nil, ErrInvalidPartialDate + } + return &value, nil +} + +// IsZero reports whether no date component is present. +func (d PartialDate) IsZero() bool { + return d.Year == nil && d.Month == nil && d.Day == nil +} + +// Validate checks component ranges and calendar validity. +func (d PartialDate) Validate() error { + if d.IsZero() { + return ErrInvalidPartialDate + } + if d.Year != nil && (*d.Year < 1 || *d.Year > 9999) { + return ErrInvalidPartialDate + } + if d.Month != nil && (*d.Month < 1 || *d.Month > 12) { + return ErrInvalidPartialDate + } + if d.Day != nil && (*d.Day < 1 || *d.Day > 31) { + return ErrInvalidPartialDate + } + if d.Year != nil && d.Month == nil && d.Day != nil { + return ErrInvalidPartialDate + } + if d.Day != nil && d.Month != nil { + year := 2000 + if d.Year != nil { + year = *d.Year + } + candidate := time.Date(year, time.Month(*d.Month), *d.Day, 0, 0, 0, 0, time.UTC) + if candidate.Year() != year || int(candidate.Month()) != *d.Month || candidate.Day() != *d.Day { + return ErrInvalidPartialDate + } + } + return nil +} + +// String renders the reduced or truncated ISO representation. +func (d PartialDate) String() string { + switch { + case d.Year != nil && d.Month != nil && d.Day != nil: + return fmt.Sprintf("%04d-%02d-%02d", *d.Year, *d.Month, *d.Day) + case d.Year != nil && d.Month != nil: + return fmt.Sprintf("%04d-%02d", *d.Year, *d.Month) + case d.Year != nil: + return fmt.Sprintf("%04d", *d.Year) + case d.Month != nil && d.Day != nil: + return fmt.Sprintf("--%02d-%02d", *d.Month, *d.Day) + case d.Month != nil: + return fmt.Sprintf("--%02d", *d.Month) + case d.Day != nil: + return fmt.Sprintf("---%02d", *d.Day) + default: + return "" + } +} + +// CompareAtSharedPrecision compares only components both values specify. +func CompareAtSharedPrecision(a, b PartialDate) int { + for _, pair := range [][2]*int{{a.Year, b.Year}, {a.Month, b.Month}, {a.Day, b.Day}} { + if pair[0] == nil || pair[1] == nil { + continue + } + if *pair[0] < *pair[1] { + return -1 + } + if *pair[0] > *pair[1] { + return 1 + } + } + return 0 +} + +// PartialDateArgs returns nullable SQL bind values in year/month/day order. +func PartialDateArgs(d PartialDate) []any { + return []any{intValue(d.Year), intValue(d.Month), intValue(d.Day)} +} + +// ScanPartialDate converts nullable component columns to a PartialDate. +func ScanPartialDate(year, month, day sql.NullInt64) PartialDate { + return PartialDate{ + Year: nullIntPtr(year), + Month: nullIntPtr(month), + Day: nullIntPtr(day), + } +} + +func intValue(value *int) any { + if value == nil { + return nil + } + return *value +} + +func nullIntPtr(value sql.NullInt64) *int { + if !value.Valid { + return nil + } + converted := int(value.Int64) + return &converted +} diff --git a/internal/store/partialdate_test.go b/internal/store/partialdate_test.go new file mode 100644 index 000000000..0ff4d08fd --- /dev/null +++ b/internal/store/partialdate_test.go @@ -0,0 +1,148 @@ +package store + +import ( + "database/sql" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPartialDateValidate(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + + tests := []struct { + name string + date PartialDate + valid bool + }{ + {name: "full date", date: partialDate(1985, 4, 12), valid: true}, + {name: "year and month", date: partialDate(1985, 4, 0), valid: true}, + {name: "year only", date: partialDate(1985, 0, 0), valid: true}, + {name: "month and day without year", date: partialDate(0, 4, 12), valid: true}, + {name: "day only", date: partialDate(0, 0, 12), valid: true}, + {name: "month only", date: partialDate(0, 4, 0), valid: true}, + {name: "empty", date: PartialDate{}}, + {name: "month zero", date: partialDate(1985, 13, 1)}, + {name: "leap day in common year", date: partialDate(1985, 2, 29)}, + {name: "leap day in leap year", date: partialDate(1984, 2, 29), valid: true}, + {name: "year and day without month", date: partialDate(1985, 0, 12)}, + } + for _, test := range tests { + err := test.date.Validate() + if test.valid { + require.NoError(err, test.name) + continue + } + assert.ErrorIs(err, ErrInvalidPartialDate, test.name) + } +} + +func TestPartialDateStringRoundTrips(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + + tests := []struct { + date PartialDate + want string + }{ + {date: partialDate(1985, 4, 12), want: "1985-04-12"}, + {date: partialDate(1985, 4, 0), want: "1985-04"}, + {date: partialDate(1985, 0, 0), want: "1985"}, + {date: partialDate(0, 4, 12), want: "--04-12"}, + {date: partialDate(0, 4, 0), want: "--04"}, + {date: partialDate(0, 0, 12), want: "---12"}, + } + for _, test := range tests { + assert.Equal(test.want, test.date.String()) + parsed, err := ParsePartialDate(test.want) + require.NoError(err, test.want) + assert.Equal(test.date, parsed, test.want) + } + _, err := ParsePartialDate("not-a-date") + require.ErrorIs(err, ErrInvalidPartialDate) + for _, malformed := range []string{"--+4", "--04+2"} { + _, err := ParsePartialDate(malformed) + require.ErrorIs(err, ErrInvalidPartialDate, malformed) + } + + compact, err := ParsePartialDate("--0412") + require.NoError(err) + assert.Equal(partialDate(0, 4, 12), compact) + assert.Equal("--04-12", compact.String()) +} + +func TestCompareAtSharedPrecisionUsesOnlyCommonComponents(t *testing.T) { + assert := assert.New(t) + + tests := []struct { + name string + a PartialDate + b PartialDate + want int + }{ + {name: "full dates ordered", a: partialDate(1985, 4, 12), b: partialDate(1985, 4, 13), want: -1}, + {name: "full dates equal", a: partialDate(1985, 4, 12), b: partialDate(1985, 4, 12), want: 0}, + {name: "years differ", a: partialDate(1984, 12, 31), b: partialDate(1985, 1, 1), want: -1}, + { + name: "coarser value equal at shared precision", + a: partialDate(1985, 0, 0), b: partialDate(1985, 4, 12), want: 0, + }, + { + name: "coarser value still ordered by the component it has", + a: partialDate(1984, 0, 0), b: partialDate(1985, 4, 12), want: -1, + }, + { + name: "year-less dates compare by month and day", + a: partialDate(0, 4, 12), b: partialDate(0, 6, 1), want: -1, + }, + { + name: "no shared component compares equal", + a: partialDate(1985, 0, 0), b: partialDate(0, 0, 12), want: 0, + }, + } + for _, test := range tests { + assert.Equal(test.want, CompareAtSharedPrecision(test.a, test.b), test.name) + assert.Equal(-test.want, CompareAtSharedPrecision(test.b, test.a), test.name+" reversed") + } +} + +func TestPartialDateComponentColumnsRoundTrip(t *testing.T) { + assert := assert.New(t) + + date := partialDate(1985, 4, 0) + args := PartialDateArgs(date) + assert.Equal([]any{1985, 4, nil}, args, + "absent components bind as NULL, never as zero") + + scanned := ScanPartialDate( + sql.NullInt64{Int64: 1985, Valid: true}, + sql.NullInt64{Int64: 4, Valid: true}, + sql.NullInt64{}, + ) + assert.Equal(date, scanned) + assert.Equal(PartialDate{}, ScanPartialDate( + sql.NullInt64{}, sql.NullInt64{}, sql.NullInt64{}, + )) +} + +// partialDate and intPtr are duplicated from the store_test package's copies +// in person_names_test.go. A helper cannot cross a package boundary, and this +// file must stay in package store to reach joinTypeTokens/splitTypeTokens. +func partialDate(year, month, day int) PartialDate { + date := PartialDate{} + if year != 0 { + value := year + date.Year = &value + } + if month != 0 { + value := month + date.Month = &value + } + if day != 0 { + value := day + date.Day = &value + } + return date +} diff --git a/internal/store/participant_display_name_revision.go b/internal/store/participant_display_name_revision.go new file mode 100644 index 000000000..57632c2e6 --- /dev/null +++ b/internal/store/participant_display_name_revision.go @@ -0,0 +1,146 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strconv" +) + +const participantDisplayNameRevisionKey = "participant_display_name_revision" + +// lockParticipantDirectoryMutationTxContext serializes multi-participant +// ensures with participant merges on PostgreSQL. Those operations otherwise +// acquire participant row locks in different logical orders and can deadlock. +// SQLite already serializes writers, so it needs no additional lock. +func (s *Store) lockParticipantDirectoryMutationTxContext( + ctx context.Context, tx *loggedTx, +) error { + if !s.IsPostgreSQL() { + return nil + } + return s.lockProfileIdentityKeyTxContext( + ctx, tx, "participant-directory-mutation", + ) +} + +// ParticipantDisplayNameRepair is one validated display-name replacement used +// by maintenance commands that must update participant data and its cache +// revision atomically. +type ParticipantDisplayNameRepair struct { + ParticipantID int64 + DisplayName string +} + +// ParticipantDisplayNameRevision returns the current participant display-name +// revision (0 if never bumped). It advances when a participant row is added +// or when an existing participant receives a previously blank display name. +// Callers use it to detect stale participant-derived cache datasets. +func (s *Store) ParticipantDisplayNameRevision() (int64, error) { + return readParticipantDisplayNameRevision(s.db) +} + +func readParticipantDisplayNameRevision(q rowQuerier) (int64, error) { + var value string + err := q.QueryRow( + `SELECT value FROM archive_metadata WHERE key = ?`, participantDisplayNameRevisionKey, + ).Scan(&value) + if errors.Is(err, sql.ErrNoRows) { + return 0, nil + } + if err != nil { + return 0, fmt.Errorf("read participant display-name revision: %w", err) + } + revision, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return 0, fmt.Errorf("parse participant display-name revision %q: %w", value, err) + } + return revision, nil +} + +// bumpParticipantDisplayNameRevision increments the participant display-name +// revision inside tx, seeding the metadata row when it does not exist yet. +func (s *Store) bumpParticipantDisplayNameRevision(tx *loggedTx) error { + return s.bumpParticipantDisplayNameRevisionContext(context.Background(), tx) +} + +func (s *Store) bumpParticipantDisplayNameRevisionContext( + ctx context.Context, + tx *loggedTx, +) error { + if _, err := tx.ExecContext(ctx, s.dialect.InsertOrIgnore( + `INSERT OR IGNORE INTO archive_metadata (key, value) VALUES (?, '0')`), + participantDisplayNameRevisionKey); err != nil { + return fmt.Errorf("seed participant display-name revision: %w", err) + } + if _, err := tx.ExecContext(ctx, + `UPDATE archive_metadata SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT) + WHERE key = ?`, + participantDisplayNameRevisionKey); err != nil { + return fmt.Errorf("bump participant display-name revision: %w", err) + } + return nil +} + +func (s *Store) bumpParticipantDisplayNameRevisionIfChanged( + tx *loggedTx, + result sql.Result, +) (bool, error) { + return s.bumpParticipantDisplayNameRevisionIfChangedContext( + context.Background(), tx, result, + ) +} + +func (s *Store) bumpParticipantDisplayNameRevisionIfChangedContext( + ctx context.Context, + tx *loggedTx, + result sql.Result, +) (bool, error) { + changed, err := result.RowsAffected() + if err != nil { + return false, fmt.Errorf("check participant display-name change: %w", err) + } + if changed <= 0 { + return false, nil + } + if err := s.bumpParticipantDisplayNameRevisionContext(ctx, tx); err != nil { + return false, err + } + return true, nil +} + +// RepairParticipantDisplayNames applies one maintenance batch and advances the +// display-name revision once when at least one participant row changes. +func (s *Store) RepairParticipantDisplayNames( + repairs []ParticipantDisplayNameRepair, +) error { + if len(repairs) == 0 { + return nil + } + return s.withTx(func(tx *loggedTx) error { + changed := false + for _, repair := range repairs { + result, err := tx.Exec(` + UPDATE participants SET display_name = ? + WHERE id = ? + AND (display_name IS NULL OR display_name <> ?) + `, repair.DisplayName, repair.ParticipantID, repair.DisplayName) + if err != nil { + return fmt.Errorf( + "repair participant display name %d: %w", + repair.ParticipantID, err, + ) + } + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("check participant display-name repair: %w", err) + } + changed = changed || rows > 0 + } + if !changed { + return nil + } + return s.bumpParticipantDisplayNameRevision(tx) + }) +} diff --git a/internal/store/participant_display_name_revision_test.go b/internal/store/participant_display_name_revision_test.go new file mode 100644 index 000000000..4620cf8a7 --- /dev/null +++ b/internal/store/participant_display_name_revision_test.go @@ -0,0 +1,387 @@ +package store_test + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/mime" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil/storetest" +) + +func TestPersistMessageWithParticipantsBumpsDisplayNameRevisionOnce(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + st := f.Store + + source, err := st.GetOrCreateSource("email", "persist-revision@example.com") + require.NoError(err) + conversationID, err := st.EnsureConversationWithType( + source.ID, "persist-revision-conversation", "email_thread", "Revision Test", + ) + require.NoError(err) + before := participantDisplayNameRevision(t, st) + _, err = st.PersistMessageWithParticipantsContext(t.Context(), []store.ParticipantPersistData{ + {EmailAddress: "persist-one@example.com", DisplayName: "Persist One", Domain: "example.com"}, + {EmailAddress: "persist-two@example.com", DisplayName: "Persist Two", Domain: "example.com"}, + }, func([]int64) *store.MessagePersistData { + return &store.MessagePersistData{Message: &store.Message{ + SourceID: source.ID, SourceMessageID: "persist-revision-message", + ConversationID: conversationID, MessageType: "email", + }} + }) + require.NoError(err) + assert.Equal(before+1, participantDisplayNameRevision(t, st), + "one message transaction must invalidate derived participant data once") +} + +func TestPostgreSQLParticipantBatchSerializesWithMerge(t *testing.T) { + require := require.New(t) + st := storetest.New(t).Store + if !st.IsPostgreSQL() { + t.Skip("PostgreSQL lock-order regression") + } + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + + absorbedID, err := st.EnsureParticipant( + "z-batch-merge@example.com", "Absorbed", "example.com", + ) + require.NoError(err) + survivorID, err := st.EnsureParticipant( + "a-batch-merge@example.com", "Survivor", "example.com", + ) + require.NoError(err) + require.Less(absorbedID, survivorID, + "fixture requires merge row order to oppose sorted email order") + + const advisoryKey int64 = 88442212 + barrier, err := st.DB().Conn(ctx) + require.NoError(err) + t.Cleanup(func() { + _, _ = barrier.ExecContext(context.Background(), "SELECT pg_advisory_unlock($1)", advisoryKey) + _ = barrier.Close() + }) + _, err = barrier.ExecContext(ctx, "SELECT pg_advisory_lock($1)", advisoryKey) + require.NoError(err) + _, err = st.DB().ExecContext(ctx, fmt.Sprintf(` + CREATE FUNCTION delay_participant_merge_update_fn() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF OLD.id = %d THEN + PERFORM pg_advisory_xact_lock(%d); + END IF; + RETURN NEW; + END + $$; + CREATE TRIGGER delay_participant_merge_update + BEFORE UPDATE ON participants + FOR EACH ROW EXECUTE FUNCTION delay_participant_merge_update_fn()`, absorbedID, advisoryKey)) + require.NoError(err) + + mergeDone := make(chan error, 1) + go func() { mergeDone <- st.MergeParticipants(absorbedID, survivorID) }() + require.Eventually(func() bool { + return postgreSQLWaitingLockCount(t, st) >= 1 + }, 5*time.Second, 10*time.Millisecond, "merge did not reach its update barrier") + + batchDone := make(chan error, 1) + go func() { + _, batchErr := st.EnsureParticipantsBatch([]mime.Address{ + {Name: "Absorbed", Email: "z-batch-merge@example.com", Domain: "example.com"}, + {Name: "Survivor", Email: "a-batch-merge@example.com", Domain: "example.com"}, + }) + batchDone <- batchErr + }() + require.Eventually(func() bool { + return postgreSQLWaitingLockCount(t, st) >= 2 + }, 5*time.Second, 10*time.Millisecond, "batch did not reach the opposing lock order") + + _, err = barrier.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", advisoryKey) + require.NoError(err) + require.NoError(<-mergeDone) + require.NoError(<-batchDone) +} + +func TestEnsureParticipantsBatchConcurrentOppositeOrderConverges(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + st := f.Store + forward := []mime.Address{ + {Name: "Concurrent One", Email: "concurrent-one@example.com", Domain: "example.com"}, + {Name: "Concurrent Two", Email: "concurrent-two@example.com", Domain: "example.com"}, + } + reverse := []mime.Address{forward[1], forward[0]} + + start := make(chan struct{}) + results := make(chan map[string]int64, 2) + errs := make(chan error, 2) + var wg sync.WaitGroup + for _, addresses := range [][]mime.Address{forward, reverse} { + wg.Add(1) + go func(batch []mime.Address) { + defer wg.Done() + <-start + result, err := st.EnsureParticipantsBatch(batch) + results <- result + errs <- err + }(addresses) + } + close(start) + wg.Wait() + close(results) + close(errs) + + for err := range errs { + require.NoError(err) + } + var first map[string]int64 + for result := range results { + require.Len(result, 2) + if first == nil { + first = result + continue + } + assert.Equal(first, result) + } +} + +func TestEnsureParticipantBumpsDisplayNameRevisionOnlyWhenCreatingParticipant(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + st := f.Store + + before := participantDisplayNameRevision(t, st) + participantID, err := st.EnsureParticipant( + "directory-revision@example.com", "Directory User", "example.com", + ) + require.NoError(err) + afterCreate := participantDisplayNameRevision(t, st) + assert.Equal(before+1, afterCreate, + "new participant must invalidate derived participant data") + + againID, err := st.EnsureParticipant( + "directory-revision@example.com", "Ignored Name", "ignored.example", + ) + require.NoError(err) + assert.Equal(participantID, againID) + assert.Equal(afterCreate, participantDisplayNameRevision(t, st), + "idempotent participant ensure must not advance the revision") +} + +func TestEnsureParticipantsBatchBumpsDisplayNameRevisionForActualInserts(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + st := f.Store + addresses := []mime.Address{ + {Name: "Batch One", Email: "batch-one@example.com", Domain: "example.com"}, + {Name: "Batch Two", Email: "batch-two@example.com", Domain: "example.com"}, + } + + before := participantDisplayNameRevision(t, st) + created, err := st.EnsureParticipantsBatch(addresses) + require.NoError(err) + require.Len(created, 2) + afterCreate := participantDisplayNameRevision(t, st) + assert.Equal(before+1, afterCreate, + "one successful batch must invalidate derived participant data once") + + again, err := st.EnsureParticipantsBatch(addresses) + require.NoError(err) + assert.Equal(created, again) + assert.Equal(afterCreate, participantDisplayNameRevision(t, st), + "idempotent batch ensure must not advance the revision") +} + +func participantDisplayNameRevision(t *testing.T, st *store.Store) int64 { + t.Helper() + revision, err := st.ParticipantDisplayNameRevision() + require.NoError(t, err, "ParticipantDisplayNameRevision") + return revision +} + +func TestEnsureParticipantByIdentifierBackfillBumpsDisplayNameRevisionOnce(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + st := f.Store + + beforeCreate := participantDisplayNameRevision(t, st) + participantID, err := st.EnsureParticipantByIdentifier( + "example", "display-name-backfill", "", + ) + require.NoError(err, "seed blank participant") + assert.Equal(beforeCreate+1, participantDisplayNameRevision(t, st), + "new identifier participant must invalidate derived participant data") + before := participantDisplayNameRevision(t, st) + + backfilledID, err := st.EnsureParticipantByIdentifier( + "example", "display-name-backfill", "Test User", + ) + require.NoError(err, "backfill participant display name") + assert.Equal(participantID, backfilledID) + afterBackfill := participantDisplayNameRevision(t, st) + assert.Equal(before+1, afterBackfill, + "display-name backfill must invalidate derived participant data") + + retryID, err := st.EnsureParticipantByIdentifier( + "example", "display-name-backfill", "Retry User", + ) + require.NoError(err, "retry participant display-name backfill") + assert.Equal(participantID, retryID) + afterRetry := participantDisplayNameRevision(t, st) + assert.Equal(afterBackfill, afterRetry, + "idempotent display-name ensure must not advance the revision") +} + +func TestEnsureParticipantByPhoneBackfillBumpsDisplayNameRevisionOnceWhenIdentifierNoop(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + st := f.Store + const phone = "+1555010200" + + beforeCreate := participantDisplayNameRevision(t, st) + participantID, err := st.EnsureParticipantByPhone(phone, "", "whatsapp") + require.NoError(err, "seed blank phone participant") + assert.Equal(beforeCreate+1, participantDisplayNameRevision(t, st), + "new phone participant must invalidate derived participant data") + beforeDisplayName := participantDisplayNameRevision(t, st) + beforeIdentifier, err := st.ParticipantIdentifierRevision() + require.NoError(err, "read participant identifier revision before backfill") + + backfilledID, err := st.EnsureParticipantByPhone(phone, "Phone User", "whatsapp") + require.NoError(err, "backfill phone participant display name") + assert.Equal(participantID, backfilledID) + afterDisplayName := participantDisplayNameRevision(t, st) + assert.Equal(beforeDisplayName+1, afterDisplayName, + "display-name backfill must invalidate derived participant data") + afterIdentifier, err := st.ParticipantIdentifierRevision() + require.NoError(err, "read participant identifier revision after backfill") + assert.Equal(beforeIdentifier, afterIdentifier, + "backfill must not bump an already-complete identifier write") + + retryID, err := st.EnsureParticipantByPhone(phone, "Retry User", "whatsapp") + require.NoError(err, "retry phone participant display-name backfill") + assert.Equal(participantID, retryID) + assert.Equal(afterDisplayName, participantDisplayNameRevision(t, st), + "idempotent display-name ensure must not advance the revision") +} + +func TestEnsureParticipantByPhoneSameWhitespaceNameDoesNotBumpDisplayNameRevision(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + st := f.Store + const phone = "+1555010203" + + participantID, err := st.EnsureParticipantByPhone(phone, " ", "whatsapp") + require.NoError(err) + before := participantDisplayNameRevision(t, st) + + againID, err := st.EnsureParticipantByPhone(phone, " ", "whatsapp") + require.NoError(err) + assert.Equal(participantID, againID) + assert.Equal(before, participantDisplayNameRevision(t, st), + "assigning the same whitespace value must not advance the revision") +} + +func TestUpdateParticipantDisplayNameByPhoneBumpsDisplayNameRevisionOnlyOnActualUpdate(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + st := f.Store + const phone = "+1555010201" + + _, err := st.EnsureParticipantByPhone(phone, "", "whatsapp") + require.NoError(err, "seed blank phone participant") + before := participantDisplayNameRevision(t, st) + + updated, err := st.UpdateParticipantDisplayNameByPhone(phone, "Phone User") + require.NoError(err, "update phone participant display name") + assert.True(updated, "expected phone display-name update") + afterUpdate := participantDisplayNameRevision(t, st) + assert.Equal(before+1, afterUpdate, + "actual phone display-name update must invalidate derived participant data") + + updated, err = st.UpdateParticipantDisplayNameByPhone(phone, "Replacement User") + require.NoError(err, "retry phone participant display-name update") + assert.False(updated, "non-empty phone display name must not be overwritten") + assert.Equal(afterUpdate, participantDisplayNameRevision(t, st), + "no-op phone display-name update must not advance the revision") +} + +func TestUpdateImessageParticipantDisplayNameByPhoneBumpsDisplayNameRevisionOnlyOnActualUpdate(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + st := f.Store + const phone = "+1555010202" + + _, err := st.EnsureParticipantByPhone(phone, phone, "imessage") + require.NoError(err, "seed legacy iMessage phone participant") + before := participantDisplayNameRevision(t, st) + + updated, err := st.UpdateImessageParticipantDisplayNameByPhone(phone, "iMessage User") + require.NoError(err, "update iMessage participant display name") + assert.True(updated, "expected iMessage display-name update") + afterUpdate := participantDisplayNameRevision(t, st) + assert.Equal(before+1, afterUpdate, + "actual iMessage display-name update must invalidate derived participant data") + + updated, err = st.UpdateImessageParticipantDisplayNameByPhone(phone, "Replacement User") + require.NoError(err, "retry iMessage participant display-name update") + assert.False(updated, "non-empty iMessage display name must not be overwritten") + assert.Equal(afterUpdate, participantDisplayNameRevision(t, st), + "no-op iMessage display-name update must not advance the revision") +} + +func TestUpdateImessageParticipantDisplayNameByPhoneSameValueIsNoop(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + st := f.Store + const phone = "+1555010204" + + _, err := st.EnsureParticipantByPhone(phone, phone, "imessage") + require.NoError(err) + before := participantDisplayNameRevision(t, st) + + updated, err := st.UpdateImessageParticipantDisplayNameByPhone(phone, phone) + require.NoError(err) + assert.False(updated) + assert.Equal(before, participantDisplayNameRevision(t, st), + "assigning the existing phone placeholder must not advance the revision") +} + +func TestUpdateParticipantDisplayNameByEmailBumpsDisplayNameRevisionOnlyOnActualUpdate(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + st := f.Store + + _, err := st.EnsureParticipant("display-name@example.com", "", "example.com") + require.NoError(err, "seed blank email participant") + before := participantDisplayNameRevision(t, st) + + updated, err := st.UpdateParticipantDisplayNameByEmail("display-name@example.com", "Email User") + require.NoError(err, "update email participant display name") + assert.True(updated, "expected email display-name update") + afterUpdate := participantDisplayNameRevision(t, st) + assert.Equal(before+1, afterUpdate, + "actual email display-name update must invalidate derived participant data") + + updated, err = st.UpdateParticipantDisplayNameByEmail("display-name@example.com", "Replacement User") + require.NoError(err, "retry email participant display-name update") + assert.False(updated, "non-empty email display name must not be overwritten") + assert.Equal(afterUpdate, participantDisplayNameRevision(t, st), + "no-op email display-name update must not advance the revision") +} diff --git a/internal/store/participant_identifier_classification.go b/internal/store/participant_identifier_classification.go new file mode 100644 index 000000000..e59bc7186 --- /dev/null +++ b/internal/store/participant_identifier_classification.go @@ -0,0 +1,59 @@ +package store + +import "strings" + +type participantIdentifierClassification struct { + ServiceSlug string + ScopeKind *string + ScopeValue *string +} + +func classifyParticipantIdentifier( + identifierType, identifierValue string, +) (participantIdentifierClassification, bool) { + kind := strings.ToLower(strings.TrimSpace(identifierType)) + value := strings.TrimSpace(identifierValue) + classification := participantIdentifierClassification{} + switch { + case kind == "imessage": + classification.ServiceSlug = "imessage" + case kind == "whatsapp": + classification.ServiceSlug = "whatsapp" + case kind == "matrix": + classification.ServiceSlug = "matrix" + if separator := strings.Index(value, ":"); separator > 0 && separator+1 < len(value) { + classification.ScopeKind = new("server") + classification.ScopeValue = new(value[separator+1:]) + } else { + return participantIdentifierClassification{}, false + } + case kind == "discord" || strings.HasPrefix(kind, "discord_"): + classification.ServiceSlug = "discord" + case kind == "synctech-sms" || kind == "synctech_sms" || kind == "sms": + classification.ServiceSlug = "sms" + case kind == "google_voice" || kind == "google-voice": + classification.ServiceSlug = "google-voice" + case kind == "slack": + classification.ServiceSlug = "slack" + if separator := strings.Index(value, ":"); separator > 0 { + classification.ScopeKind = new("workspace") + classification.ScopeValue = new(value[:separator]) + } else { + return participantIdentifierClassification{}, false + } + default: + return participantIdentifierClassification{}, false + } + return classification, true +} + +func participantIdentifierClassificationValues( + identifierType, identifierValue string, +) (serviceSlug, scopeKind, scopeValue any) { + classification, ok := classifyParticipantIdentifier(identifierType, identifierValue) + if !ok { + return nil, nil, nil + } + return classification.ServiceSlug, + stringValue(classification.ScopeKind), stringValue(classification.ScopeValue) +} diff --git a/internal/store/participant_identifier_classification_test.go b/internal/store/participant_identifier_classification_test.go new file mode 100644 index 000000000..6eed4ddd5 --- /dev/null +++ b/internal/store/participant_identifier_classification_test.go @@ -0,0 +1,128 @@ +package store_test + +import ( + "database/sql" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil/storetest" +) + +func assertParticipantIdentifierClassification( + t *testing.T, + st *store.Store, + identifierType, identifierValue, serviceSlug, scopeKind, scopeValue string, +) { + t.Helper() + var gotService, gotScopeKind, gotScopeValue sql.NullString + err := st.DB().QueryRow(st.Rebind(`SELECT cs.slug, pi.scope_kind, pi.scope_value + FROM participant_identifiers pi + LEFT JOIN communication_services cs ON cs.id = pi.service_id + WHERE pi.identifier_type = ? AND pi.identifier_value = ?`), + identifierType, identifierValue, + ).Scan(&gotService, &gotScopeKind, &gotScopeValue) + require.NoError(t, err) + assert.Equal(t, serviceSlug, gotService.String, "service slug") + assert.Equal(t, serviceSlug != "", gotService.Valid, "service presence") + assert.Equal(t, scopeKind, gotScopeKind.String, "scope kind") + assert.Equal(t, scopeKind != "", gotScopeKind.Valid, "scope-kind presence") + assert.Equal(t, scopeValue, gotScopeValue.String, "scope value") + assert.Equal(t, scopeValue != "", gotScopeValue.Valid, "scope-value presence") +} + +func TestParticipantIdentifierWritePathsClassifyServiceAndScope(t *testing.T) { + require := require.New(t) + f := storetest.New(t) + st := f.Store + + _, err := st.EnsureParticipantByPhone( + "+15550100001", "Test User", "whatsapp", + ) + require.NoError(err) + assertParticipantIdentifierClassification( + t, st, "whatsapp", "+15550100001", "whatsapp", "", "", + ) + + _, err = st.EnsureParticipantByIdentifier( + "discord_user_id", "discord-user-1", "Test User", + ) + require.NoError(err) + assertParticipantIdentifierClassification( + t, st, "discord_user_id", "discord-user-1", "discord", "", "", + ) + + participantID := f.EnsureParticipant( + "slack-user@example.com", "Test User", "example.com", + ) + require.NoError(st.SetParticipantIdentifier( + participantID, "slack", "T-SYNTHETIC:U-SYNTHETIC", + )) + assertParticipantIdentifierClassification( + t, st, "slack", "T-SYNTHETIC:U-SYNTHETIC", + "slack", "workspace", "T-SYNTHETIC", + ) + + for _, tc := range []struct { + identifierType string + identifierValue string + serviceSlug string + scopeKind string + scopeValue string + }{ + {"matrix", "@alice:matrix.example:8448", "matrix", "server", "matrix.example:8448"}, + {"matrix", "alice-without-server", "", "", ""}, + {"slack", "user-without-workspace", "", "", ""}, + {"synctech_sms", "22000", "sms", "", ""}, + {"google_voice", "+15550100002", "google-voice", "", ""}, + {"beeper", "@alice:beeper.local", "", "", ""}, + {"example-unknown", "alice", "", "", ""}, + } { + _, err = st.EnsureParticipantByIdentifier( + tc.identifierType, tc.identifierValue, "Test User", + ) + require.NoError(err) + assertParticipantIdentifierClassification( + t, st, tc.identifierType, tc.identifierValue, + tc.serviceSlug, tc.scopeKind, tc.scopeValue, + ) + } +} + +func TestParticipantIdentifierServiceScopeV2RepairsAlreadyMigratedRows(t *testing.T) { + require := require.New(t) + f := storetest.New(t) + st := f.Store + + _, err := st.EnsureParticipantByIdentifier( + "imessage", "+15550100003", "Test User", + ) + require.NoError(err) + _, err = st.EnsureParticipantByIdentifier( + "slack", "T-REPAIR:U-REPAIR", "Test User", + ) + require.NoError(err) + _, err = st.DB().Exec(`UPDATE participant_identifiers + SET service_id = NULL, scope_kind = NULL, scope_value = NULL + WHERE identifier_value IN ('+15550100003', 'T-REPAIR:U-REPAIR')`) + require.NoError(err) + _, err = st.DB().Exec(st.Rebind( + `DELETE FROM applied_migrations WHERE name = ?`), + "participant_identifiers_service_scope_v2", + ) + require.NoError(err) + + require.NoError(st.InitSchema()) + + assertParticipantIdentifierClassification( + t, st, "imessage", "+15550100003", "imessage", "", "", + ) + assertParticipantIdentifierClassification( + t, st, "slack", "T-REPAIR:U-REPAIR", + "slack", "workspace", "T-REPAIR", + ) + applied, err := st.IsMigrationApplied("participant_identifiers_service_scope_v2") + require.NoError(err) + assert.True(t, applied) +} diff --git a/internal/store/participant_identifier_revision.go b/internal/store/participant_identifier_revision.go index 702f6c602..0cb7ce8ee 100644 --- a/internal/store/participant_identifier_revision.go +++ b/internal/store/participant_identifier_revision.go @@ -11,13 +11,13 @@ import ( const participantIdentifierRevisionKey = "participant_identifier_revision" // ParticipantIdentifierRevision returns the current participant-identifier -// revision (0 if never bumped). It increments whenever SetParticipantIdentifier -// actually changes an identifier mapping. Identifiers bake into the identity -// directory datasets (relationship_people search values and display labels, -// the participant_identifiers Parquet export) but not into per-row activity -// facts, so drift on this revision alone is repairable by the derived-dataset -// refresh — unlike AccountIdentityRevision drift, it never demands a full -// rebuild, and coinciding new messages stay incremental. +// revision (0 if never bumped). It increments whenever an identifier row is +// created, repointed, or given new service/scope classification. Identifiers +// bake into the identity directory datasets (relationship_people search values +// and display labels, the participant_identifiers Parquet export) but not into +// per-row activity facts, so drift on this revision alone is repairable by the +// derived-dataset refresh — unlike AccountIdentityRevision drift, it never +// demands a full rebuild, and coinciding new messages stay incremental. func (s *Store) ParticipantIdentifierRevision() (int64, error) { var value string err := s.db.QueryRow( @@ -54,3 +54,16 @@ func (s *Store) bumpParticipantIdentifierRevision(tx *loggedTx) error { } return nil } + +func (s *Store) bumpParticipantIdentifierRevisionIfChanged( + tx *loggedTx, result sql.Result, +) error { + changed, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("check participant identifier change: %w", err) + } + if changed == 0 { + return nil + } + return s.bumpParticipantIdentifierRevision(tx) +} diff --git a/internal/store/participant_identifiers_test.go b/internal/store/participant_identifiers_test.go index 1923c6074..2e626b9af 100644 --- a/internal/store/participant_identifiers_test.go +++ b/internal/store/participant_identifiers_test.go @@ -151,3 +151,83 @@ func TestSetParticipantIdentifierNonOwnerEvidenceBumpsOnlyIdentifierRevision(t * require.NoError(err, "ParticipantByIdentifier") assert.Equal(alias, id, "identifier mapping must still be written") } + +func TestEnsureParticipantByIdentifierBumpsRevisionOnlyWhenCreatingIdentifier(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + st := f.Store + + _, _, before := readRevisions(t, f) + participantID, err := st.EnsureParticipantByIdentifier( + "example", "revision-user", "Revision User", + ) + require.NoError(err) + _, _, afterCreate := readRevisions(t, f) + assert.Equal(before+1, afterCreate, "new identifier must invalidate derived identity data") + + againID, err := st.EnsureParticipantByIdentifier( + "example", "revision-user", "Revision User", + ) + require.NoError(err) + assert.Equal(participantID, againID) + _, _, afterRetry := readRevisions(t, f) + assert.Equal(afterCreate, afterRetry, "idempotent ensure must not advance the revision") +} + +func TestEnsureParticipantByPhoneBumpsRevisionOnlyForIdentifierChanges(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + st := f.Store + const phone = "+1555010199" + + _, _, before := readRevisions(t, f) + participantID, err := st.EnsureParticipantByPhone(phone, "Revision User", "whatsapp") + require.NoError(err) + _, _, afterCreate := readRevisions(t, f) + assert.Equal(before+1, afterCreate, "new phone identifier must invalidate derived identity data") + + againID, err := st.EnsureParticipantByPhone(phone, "Revision User", "whatsapp") + require.NoError(err) + assert.Equal(participantID, againID) + _, _, afterRetry := readRevisions(t, f) + assert.Equal(afterCreate, afterRetry, "idempotent ensure must not advance the revision") + + _, err = st.DB().Exec(st.Rebind(`UPDATE participant_identifiers + SET service_id = NULL, scope_kind = NULL, scope_value = NULL + WHERE identifier_type = ? AND identifier_value = ?`), "whatsapp", phone) + require.NoError(err) + _, _, beforeRepair := readRevisions(t, f) + _, err = st.EnsureParticipantByPhone(phone, "Revision User", "whatsapp") + require.NoError(err) + _, _, afterRepair := readRevisions(t, f) + assert.Equal(beforeRepair+1, afterRepair, + "service metadata repair must invalidate derived identity data") + + _, err = st.EnsureParticipantByPhone(phone, "Revision User", "whatsapp") + require.NoError(err) + _, _, afterRepairRetry := readRevisions(t, f) + assert.Equal(afterRepair, afterRepairRetry, + "idempotent metadata ensure must not advance the revision") + + _, err = st.DB().Exec(st.Rebind(`UPDATE participant_identifiers + SET scope_kind = '', scope_value = '' + WHERE identifier_type = ? AND identifier_value = ?`), "whatsapp", phone) + require.NoError(err) + _, _, beforeNullRepair := readRevisions(t, f) + _, err = st.EnsureParticipantByPhone(phone, "Revision User", "whatsapp") + require.NoError(err) + _, _, afterNullRepair := readRevisions(t, f) + assert.Equal(beforeNullRepair+1, afterNullRepair, + "empty scope normalization must invalidate derived identity data") + + var nullScopes int + require.NoError(st.DB().QueryRow(st.Rebind(`SELECT COUNT(*) + FROM participant_identifiers + WHERE identifier_type = ? AND identifier_value = ? + AND scope_kind IS NULL AND scope_value IS NULL`), + "whatsapp", phone, + ).Scan(&nullScopes)) + assert.Equal(1, nullScopes) +} diff --git a/internal/store/participant_observations.go b/internal/store/participant_observations.go new file mode 100644 index 000000000..7629abe64 --- /dev/null +++ b/internal/store/participant_observations.go @@ -0,0 +1,741 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +type ParticipantContactObservation struct { + Envelope ValueEnvelope `json:"envelope"` + ParticipantID int64 `json:"participant_id"` + SourceID *int64 `json:"source_id,omitempty"` + AddressKind ContactAddressKind `json:"address_kind"` + ServiceSlug *string `json:"service_slug,omitempty"` + ScopeKind *string `json:"scope_kind,omitempty"` + ScopeValue *string `json:"scope_value,omitempty"` + ProviderUserID *string `json:"provider_user_id,omitempty"` + OriginalValue string `json:"original_value"` + NormalizedValue string `json:"normalized_value"` + Normalization string `json:"normalization"` + NormalizationVersion int `json:"normalization_version"` + ObservedAt *time.Time `json:"observed_at,omitempty"` +} + +type ParticipantContactObservationInput struct { + SourceID *int64 + AddressKind ContactAddressKind + ServiceSlug *string + ScopeKind *string + ScopeValue *string + ProviderUserID *string + OriginalValue string + ObservedAt *time.Time + Envelope ValueEnvelopeInput +} + +type RecordContactObservationResult struct { + Observation *ParticipantContactObservation `json:"observation"` + Created bool `json:"created"` + Conflicting bool `json:"conflicting"` + CandidateID *int64 `json:"candidate_id,omitempty"` +} + +var ErrObservationValueMissing = errors.New("participant contact observation requires a non-empty value") + +func (s *Store) lockParticipantObservationOwnerTxContext( + ctx context.Context, tx *loggedTx, participantID int64, +) error { + return s.lockProfileIdentityKeyTxContext( + ctx, tx, "participant-contact-observation-owner", participantID, + ) +} + +func (s *Store) lockParticipantObservationMergeTx( + ctx context.Context, tx *loggedTx, leftID, rightID int64, +) error { + if leftID > rightID { + leftID, rightID = rightID, leftID + } + if err := s.lockParticipantObservationOwnerTxContext(ctx, tx, leftID); err != nil { + return err + } + return s.lockParticipantObservationOwnerTxContext(ctx, tx, rightID) +} + +// rewriteObservationsForMergeTx preserves archive contact evidence when one +// participant is absorbed into another. Current rows with the same logical +// identity collapse to the survivor's row; a stable provider ID fills an empty +// survivor value before the absorbed duplicate is closed. Historical rows do +// not participate in the current-value identity and are always repointed. +func (s *Store) rewriteObservationsForMergeTx( + ctx context.Context, tx *loggedTx, absorbedID, survivorID int64, +) error { + absorbedCurrent := `absorbed.active_until IS NULL AND absorbed.superseded_at IS NULL` + survivorCurrent := `survivor.active_until IS NULL AND survivor.superseded_at IS NULL` + matchingIdentity := ` + (survivor.source_id = absorbed.source_id OR + (survivor.source_id IS NULL AND absorbed.source_id IS NULL)) + AND survivor.address_kind = absorbed.address_kind + AND (survivor.service_id = absorbed.service_id OR + (survivor.service_id IS NULL AND absorbed.service_id IS NULL)) + AND (survivor.scope_kind = absorbed.scope_kind OR + (survivor.scope_kind IS NULL AND absorbed.scope_kind IS NULL)) + AND (survivor.scope_value = absorbed.scope_value OR + (survivor.scope_value IS NULL AND absorbed.scope_value IS NULL)) + AND survivor.normalized_value = absorbed.normalized_value` + + if _, err := tx.ExecContext(ctx, ` + UPDATE participant_contact_observations AS survivor + SET provider_user_id = COALESCE(survivor.provider_user_id, ( + SELECT absorbed.provider_user_id + FROM participant_contact_observations AS absorbed + WHERE absorbed.participant_id = ? + AND `+absorbedCurrent+` + AND absorbed.provider_user_id IS NOT NULL + AND `+matchingIdentity+` + ORDER BY absorbed.id + LIMIT 1 + )), updated_at = `+s.dialect.Now()+` + WHERE survivor.participant_id = ? + AND `+survivorCurrent+` + AND EXISTS ( + SELECT 1 FROM participant_contact_observations AS absorbed + WHERE absorbed.participant_id = ? + AND `+absorbedCurrent+` + AND `+matchingIdentity+` + )`, absorbedID, survivorID, absorbedID); err != nil { + return fmt.Errorf("merge participant observation provider IDs: %w", err) + } + + now := s.dialect.Now() + if _, err := tx.ExecContext(ctx, ` + UPDATE participant_contact_observations + SET active_until = CASE WHEN active_from > `+now+` + THEN active_from ELSE `+now+` END, + superseded_at = `+now+`, updated_at = `+now+` + WHERE participant_id = ? + AND active_until IS NULL AND superseded_at IS NULL + AND EXISTS ( + SELECT 1 FROM participant_contact_observations AS survivor + WHERE survivor.participant_id = ? + AND survivor.active_until IS NULL AND survivor.superseded_at IS NULL + AND (survivor.source_id = participant_contact_observations.source_id OR + (survivor.source_id IS NULL AND participant_contact_observations.source_id IS NULL)) + AND survivor.address_kind = participant_contact_observations.address_kind + AND (survivor.service_id = participant_contact_observations.service_id OR + (survivor.service_id IS NULL AND participant_contact_observations.service_id IS NULL)) + AND (survivor.scope_kind = participant_contact_observations.scope_kind OR + (survivor.scope_kind IS NULL AND participant_contact_observations.scope_kind IS NULL)) + AND (survivor.scope_value = participant_contact_observations.scope_value OR + (survivor.scope_value IS NULL AND participant_contact_observations.scope_value IS NULL)) + AND survivor.normalized_value = participant_contact_observations.normalized_value + )`, absorbedID, survivorID); err != nil { + return fmt.Errorf("close duplicate merged participant observations: %w", err) + } + if _, err := tx.ExecContext(ctx, `UPDATE participant_contact_observations + SET participant_id = ? WHERE participant_id = ?`, survivorID, absorbedID); err != nil { + return fmt.Errorf("repoint merged participant observations: %w", err) + } + return nil +} + +func (s *Store) RecordContactObservationContext( + ctx context.Context, participantID int64, input ParticipantContactObservationInput, +) (*RecordContactObservationResult, error) { + if !input.AddressKind.Valid() { + return nil, ErrInvalidContactAddressKind + } + if err := input.Envelope.Validate(); err != nil { + return nil, err + } + if strings.TrimSpace(input.OriginalValue) == "" { + return nil, ErrObservationValueMissing + } + input.ScopeKind = trimmedOrNil(input.ScopeKind) + input.ScopeValue = trimmedOrNil(input.ScopeValue) + input.ProviderUserID = trimmedOrNil(input.ProviderUserID) + service, hasService, err := s.resolveOptionalCommunicationServiceContext(ctx, input.ServiceSlug) + if err != nil { + return nil, err + } + if err := ValidateServiceScope(service, input.ScopeKind, input.ScopeValue); err != nil { + return nil, err + } + normalized, err := NormalizeServiceValue(service, input.AddressKind, input.OriginalValue) + if err != nil { + return nil, err + } + normalization := fallbackContactNormalization(input.AddressKind) + normalizationVersion := 1 + var serviceID any + if hasService { + serviceID = service.ID + normalization = service.Normalization + normalizationVersion = service.NormalizationVersion + } + result := &RecordContactObservationResult{} + err = s.withTxContext(ctx, func(tx *loggedTx) error { + // Observation writes can create participant-level identity conflicts. + // Use the same outer lock as conflict cleanup, source removal, and + // participant merge so none of them can act on a stale observation set. + if err := s.lockIdentityMutationTxContext(ctx, tx); err != nil { + return err + } + // Normal source removal takes this source-scoped lock before it + // recomputes generated conflicts and deletes the source. An importer for + // that source must finish its observation/candidate transaction before + // the cleanup snapshot, or start after the source has gone and fail its + // foreign key check. Serialized removal gets the same exclusion from its + // table locks. + if input.SourceID != nil { + if err := s.lockProfileIdentityKeyTxContext( + ctx, tx, "source-contact-observation", *input.SourceID, + ); err != nil { + return err + } + } + // Participant merge takes these owner locks in ID order before it + // rewrites observations. Recording takes the one owner lock before its + // narrower identity-key lock, so a merge cannot delete the participant + // between validation and insert. + if err := s.lockParticipantObservationOwnerTxContext( + ctx, tx, participantID, + ); err != nil { + return err + } + var exists int + if err := tx.QueryRowContext(ctx, + `SELECT COUNT(*) FROM participants WHERE id = ?`, participantID, + ).Scan(&exists); err != nil { + return fmt.Errorf("check participant: %w", err) + } + if exists == 0 { + return ErrParticipantNotFound + } + if err := s.lockProfileIdentityKeyTxContext( + ctx, tx, "participant-contact-observation", + participantID, int64Value(input.SourceID), input.AddressKind, serviceID, + stringValue(input.ScopeKind), stringValue(input.ScopeValue), normalized, + ); err != nil { + return err + } + observation, err := findParticipantObservationTx( + ctx, tx, participantID, input.SourceID, input.AddressKind, serviceID, + input.ScopeKind, input.ScopeValue, normalized, + ) + providerContradicted := false + observationEnriched := false + if err == nil { + sameProvider := observation.ProviderUserID != nil && + input.ProviderUserID != nil && + *observation.ProviderUserID == *input.ProviderUserID + if input.ProviderUserID == nil || sameProvider { + result.Observation = observation + return nil + } + if observation.ProviderUserID == nil { + if _, err := tx.ExecContext(ctx, + `UPDATE participant_contact_observations + SET provider_user_id = ?, updated_at = `+s.dialect.Now()+` + WHERE id = ?`, + stringValue(input.ProviderUserID), observation.Envelope.ID, + ); err != nil { + return fmt.Errorf("update observation provider user ID: %w", err) + } + observation, err = getParticipantObservationTx( + ctx, tx, participantID, observation.Envelope.ID, + ) + if err != nil { + return err + } + if err := s.deleteUnsupportedObservationIdentityConflictsContext( + ctx, tx, + ); err != nil { + return err + } + result.Observation = observation + observationEnriched = true + } else { + // A different non-null provider ID contradicts the current row. + // Close it and record the new binding as a fresh observation so + // both facts survive in history. + if err := s.supersedeObservationRowTx( + ctx, tx, observation.Envelope.ID, + ); err != nil { + return err + } + providerContradicted = true + } + } else if !errors.Is(err, ErrProfileValueNotFound) { + return err + } + if !observationEnriched { + env, err := resolveProfileEnvelopeForOwnerTx( + ctx, tx, "participant_contact_observations", "participant_id", "address_kind", + participantID, input.AddressKind, input.Envelope, + ) + if err != nil { + return err + } + args := []any{ + participantID, int64Value(input.SourceID), input.AddressKind, serviceID, + stringValue(input.ScopeKind), stringValue(input.ScopeValue), + stringValue(input.ProviderUserID), input.OriginalValue, normalized, + normalization, normalizationVersion, timeValue(input.ObservedAt), + } + args = append(args, profileEnvelopeArgs(env)...) + var id int64 + if err := tx.QueryRowContext(ctx, `INSERT INTO participant_contact_observations ( + participant_id, source_id, address_kind, service_id, scope_kind, + scope_value, provider_user_id, original_value, normalized_value, + normalization, normalization_version, observed_at, `+ + profileEnvelopeWriteColumns+`, created_at, updated_at + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + `+s.dialect.Now()+`, `+s.dialect.Now()+` + ) RETURNING id`, args...).Scan(&id); err != nil { + return fmt.Errorf("record participant contact observation: %w", err) + } + result.Observation, err = getParticipantObservationTx(ctx, tx, participantID, id) + if err != nil { + return err + } + result.Created = true + if err := s.bumpParticipantIdentifierRevision(tx); err != nil { + return err + } + if providerContradicted { + // Conflicts generated against the superseded provider binding may + // no longer be supported by any current observation pair. + if err := s.deleteUnsupportedObservationIdentityConflictsContext( + ctx, tx, + ); err != nil { + return err + } + } + } + + otherParticipantIDs, err := findConflictingObservationParticipantIDsTx( + ctx, tx, participantID, input.AddressKind, serviceID, + input.ScopeKind, input.ScopeValue, normalized, input.ProviderUserID, + ) + if err != nil { + return err + } + if len(otherParticipantIDs) == 0 { + return nil + } + basis := identityMatchBasisForAddressKind(input.AddressKind) + for _, otherParticipantID := range otherParticipantIDs { + leftKind, leftID, rightKind, rightID, err := canonicalMatchEndpoints( + IdentityMatchParticipant, participantID, + IdentityMatchParticipant, otherParticipantID, + ) + if err != nil { + return err + } + candidateInput := IdentityMatchCandidateInput{ + LeftKind: leftKind, LeftID: leftID, RightKind: rightKind, RightID: rightID, + Basis: basis, ServiceSlug: input.ServiceSlug, ScopeKind: input.ScopeKind, + ScopeValue: input.ScopeValue, NormalizedValue: &normalized, + State: IdentityMatchStateConflict, Source: ProvenanceArchiveObservation, + } + candidate, _, err := s.upsertIdentityMatchCandidateTx( + ctx, tx, candidateInput, leftKind, leftID, rightKind, rightID, serviceID, true, + ) + if err != nil { + return err + } + if result.CandidateID == nil { + result.CandidateID = &candidate.ID + } + } + result.Conflicting = true + return nil + }) + return result, err +} + +// supersedeObservationRowTx closes one current observation row in place, +// mirroring the close semantics used by merge deduplication. +func (s *Store) supersedeObservationRowTx( + ctx context.Context, tx *loggedTx, observationID int64, +) error { + now := s.dialect.Now() + if _, err := tx.ExecContext(ctx, `UPDATE participant_contact_observations + SET active_until = CASE WHEN active_from > `+now+` + THEN active_from ELSE `+now+` END, + superseded_at = `+now+`, updated_at = `+now+` + WHERE id = ?`, observationID); err != nil { + return fmt.Errorf("supersede contradicted participant observation: %w", err) + } + return nil +} + +func (s *Store) ListParticipantObservationsContext( + ctx context.Context, participantID int64, currentOnly bool, +) ([]ParticipantContactObservation, error) { + query := participantObservationSelect + ` WHERE o.participant_id = ?` + if currentOnly { + query += ` AND o.active_until IS NULL AND o.superseded_at IS NULL` + } + query += ` ORDER BY o.address_kind, o.ordinal, o.id` + return s.queryParticipantObservationsContext(ctx, query, participantID) +} + +func (s *Store) listObservationsForPersonTx( + ctx context.Context, tx *loggedTx, personID int64, +) ([]ParticipantContactObservation, error) { + query := participantObservationSelect + ` + WHERE EXISTS ( + SELECT 1 FROM person_participants pp + WHERE pp.participant_id = o.participant_id AND pp.person_id = ? + ) + ORDER BY o.participant_id, o.id` + return queryProfileRowsTx(ctx, tx, query, scanParticipantObservation, personID) +} + +func (s *Store) FindObservationsByAddressContext( + ctx context.Context, query ContactPointQuery, +) ([]ParticipantContactObservation, error) { + if !query.AddressKind.Valid() { + return nil, ErrInvalidContactAddressKind + } + query.ScopeKind = trimmedOrNil(query.ScopeKind) + query.ScopeValue = trimmedOrNil(query.ScopeValue) + service, hasService, err := s.resolveOptionalCommunicationServiceContext(ctx, query.ServiceSlug) + if err != nil { + return nil, err + } + var serviceID any + if hasService { + serviceID = service.ID + } + return s.queryParticipantObservationsContext(ctx, participantObservationSelect+` + WHERE o.address_kind = ? + AND (o.service_id = ? OR (o.service_id IS NULL AND CAST(? AS BIGINT) IS NULL)) + AND (o.scope_kind = ? OR (o.scope_kind IS NULL AND CAST(? AS TEXT) IS NULL)) + AND (o.scope_value = ? OR (o.scope_value IS NULL AND CAST(? AS TEXT) IS NULL)) + AND o.normalized_value = ? + AND o.active_until IS NULL AND o.superseded_at IS NULL + ORDER BY o.participant_id, o.id`, + query.AddressKind, serviceID, serviceID, + stringValue(query.ScopeKind), stringValue(query.ScopeKind), + stringValue(query.ScopeValue), stringValue(query.ScopeValue), + query.NormalizedValue, + ) +} + +func (s *Store) SupersedeParticipantObservationContext( + ctx context.Context, participantID, observationID int64, activeUntil *time.Time, +) error { + return s.withTxContext(ctx, func(tx *loggedTx) error { + if err := s.lockIdentityMutationTxContext(ctx, tx); err != nil { + return err + } + if err := s.lockParticipantObservationOwnerTxContext( + ctx, tx, participantID, + ); err != nil { + return err + } + if err := s.validateProfileValueCloseTimeTx( + ctx, tx, "participant_contact_observations", "participant_id", + participantID, observationID, activeUntil, + ); err != nil { + return err + } + result, err := tx.ExecContext(ctx, `UPDATE participant_contact_observations + SET active_until = COALESCE(active_until, ?, + CASE WHEN active_from > `+s.dialect.Now()+` + THEN active_from ELSE `+s.dialect.Now()+` END), + superseded_at = `+s.dialect.Now()+`, + updated_at = `+s.dialect.Now()+` + WHERE id = ? AND participant_id = ? + AND superseded_at IS NULL`, + timeValue(activeUntil), observationID, participantID, + ) + if err != nil { + return fmt.Errorf("supersede participant observation: %w", err) + } + changed, err := result.RowsAffected() + if err != nil { + return err + } + if changed == 0 { + return ErrProfileValueNotFound + } + if err := s.deleteUnsupportedObservationIdentityConflictsContext( + ctx, tx, + ); err != nil { + return err + } + return s.bumpParticipantIdentifierRevision(tx) + }) +} + +// deleteUnsupportedObservationIdentityConflictsContext removes generated +// conflicts and returns promoted candidates to their pre-conflict state after +// the observations that support them change. A conflict remains reviewable +// while a matching current observation pair exists whose stable provider IDs +// are absent or different. +func (s *Store) deleteUnsupportedObservationIdentityConflictsContext( + ctx context.Context, execer contextQuerier, +) error { + if _, err := execer.ExecContext(ctx, s.dialect.Rebind(` + WITH stale_conflicts AS ( + SELECT c.id + FROM identity_match_candidates c + WHERE c.left_kind = 'participant' + AND c.right_kind = 'participant' + AND c.state = 'conflict' + AND c.observation_conflict_origin = 'promoted' + AND c.normalized_value IS NOT NULL + AND c.basis IN ('email', 'phone', 'service_scope_username') + AND NOT EXISTS ( + SELECT 1 FROM participant_contact_observations current_left + WHERE current_left.participant_id = c.left_id + AND `+identityCandidateObservationMatchSQL("current_left")+` + AND EXISTS ( + SELECT 1 FROM participant_contact_observations current_right + WHERE current_right.participant_id = c.right_id + AND `+identityCandidateObservationMatchSQL("current_right")+` + AND `+identityCandidateObservationPairSQL( + "current_left", "current_right", + )+` + ) + ) + ) + UPDATE identity_match_candidates + SET state = COALESCE(pre_conflict_state, 'candidate'), + observation_conflict_origin = NULL, pre_conflict_state = NULL, + updated_at = `+s.dialect.Now()+` + WHERE id IN (SELECT id FROM stale_conflicts)`)); err != nil { + return fmt.Errorf("demote unsupported observation conflicts: %w", err) + } + if _, err := execer.ExecContext(ctx, s.dialect.Rebind(` + WITH stale_conflicts AS ( + SELECT c.id + FROM identity_match_candidates c + WHERE c.left_kind = 'participant' + AND c.right_kind = 'participant' + AND c.state = 'conflict' + AND c.observation_conflict_origin = 'generated' + AND c.normalized_value IS NOT NULL + AND c.basis IN ('email', 'phone', 'service_scope_username') + AND NOT EXISTS ( + SELECT 1 FROM participant_contact_observations current_left + WHERE current_left.participant_id = c.left_id + AND `+identityCandidateObservationMatchSQL("current_left")+` + AND EXISTS ( + SELECT 1 FROM participant_contact_observations current_right + WHERE current_right.participant_id = c.right_id + AND `+identityCandidateObservationMatchSQL("current_right")+` + AND `+identityCandidateObservationPairSQL( + "current_left", "current_right", + )+` + ) + ) + ) + DELETE FROM identity_match_candidates + WHERE id IN (SELECT id FROM stale_conflicts)`)); err != nil { + return fmt.Errorf("delete unsupported observation conflicts: %w", err) + } + return nil +} + +// identityCandidateObservationPairSQL matches a supporting observation pair: +// conflict generation only ever pairs observations of the same address kind, +// so a cross-kind pair (say username vs social) must not keep a conflict +// alive either. +func identityCandidateObservationPairSQL(leftAlias, rightAlias string) string { + return leftAlias + `.address_kind = ` + rightAlias + `.address_kind + AND (` + leftAlias + `.provider_user_id IS NULL + OR ` + rightAlias + `.provider_user_id IS NULL + OR ` + leftAlias + `.provider_user_id != ` + rightAlias + `.provider_user_id)` +} + +func (s *Store) queryParticipantObservationsContext( + ctx context.Context, query string, args ...any, +) ([]ParticipantContactObservation, error) { + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("query participant observations: %w", err) + } + defer func() { _ = rows.Close() }() + observations := make([]ParticipantContactObservation, 0) + for rows.Next() { + observation, err := scanParticipantObservation(rows) + if err != nil { + return nil, fmt.Errorf("scan participant observation: %w", err) + } + observations = append(observations, *observation) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("query participant observations: %w", err) + } + return observations, nil +} + +func findParticipantObservationTx( + ctx context.Context, + tx *loggedTx, + participantID int64, + sourceID *int64, + addressKind ContactAddressKind, + serviceID any, + scopeKind, scopeValue *string, + normalized string, +) (*ParticipantContactObservation, error) { + observation, err := scanParticipantObservation(tx.QueryRowContext(ctx, + participantObservationSelect+` + WHERE o.participant_id = ? + AND (o.source_id = ? OR (o.source_id IS NULL AND CAST(? AS BIGINT) IS NULL)) + AND o.address_kind = ? + AND (o.service_id = ? OR (o.service_id IS NULL AND CAST(? AS BIGINT) IS NULL)) + AND (o.scope_kind = ? OR (o.scope_kind IS NULL AND CAST(? AS TEXT) IS NULL)) + AND (o.scope_value = ? OR (o.scope_value IS NULL AND CAST(? AS TEXT) IS NULL)) + AND o.normalized_value = ? + AND o.active_until IS NULL AND o.superseded_at IS NULL`, + participantID, int64Value(sourceID), int64Value(sourceID), addressKind, + serviceID, serviceID, + stringValue(scopeKind), stringValue(scopeKind), + stringValue(scopeValue), stringValue(scopeValue), normalized, + )) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrProfileValueNotFound + } + return observation, err +} + +func findConflictingObservationParticipantIDsTx( + ctx context.Context, + tx *loggedTx, + participantID int64, + addressKind ContactAddressKind, + serviceID any, + scopeKind, scopeValue *string, + normalized string, + providerUserID *string, +) ([]int64, error) { + rows, err := tx.QueryContext(ctx, `SELECT participant_id, provider_user_id + FROM participant_contact_observations + WHERE participant_id != ? AND address_kind = ? + AND (service_id = ? OR (service_id IS NULL AND CAST(? AS BIGINT) IS NULL)) + AND (scope_kind = ? OR (scope_kind IS NULL AND CAST(? AS TEXT) IS NULL)) + AND (scope_value = ? OR (scope_value IS NULL AND CAST(? AS TEXT) IS NULL)) + AND normalized_value = ? + AND active_until IS NULL AND superseded_at IS NULL + ORDER BY participant_id, id`, + participantID, addressKind, serviceID, serviceID, + stringValue(scopeKind), stringValue(scopeKind), + stringValue(scopeValue), stringValue(scopeValue), normalized, + ) + if err != nil { + return nil, fmt.Errorf("find conflicting observation: %w", err) + } + defer func() { _ = rows.Close() }() + participantIDs := make([]int64, 0) + seen := make(map[int64]struct{}) + for rows.Next() { + var otherID int64 + var otherProvider sql.NullString + if err := rows.Scan(&otherID, &otherProvider); err != nil { + return nil, err + } + if providerUserID != nil && otherProvider.Valid && otherProvider.String == *providerUserID { + continue + } + if _, ok := seen[otherID]; ok { + continue + } + seen[otherID] = struct{}{} + participantIDs = append(participantIDs, otherID) + } + if err := rows.Err(); err != nil { + return nil, err + } + return participantIDs, nil +} + +func identityMatchBasisForAddressKind(kind ContactAddressKind) IdentityMatchBasis { + switch kind { + case ContactAddressEmail: + return IdentityMatchEmail + case ContactAddressPhone: + return IdentityMatchPhone + default: + return IdentityMatchServiceScopeUsername + } +} + +const participantObservationSelect = `SELECT + o.id, o.participant_id, o.source_id, o.address_kind, cs.slug, + o.scope_kind, o.scope_value, o.provider_user_id, o.original_value, + o.normalized_value, o.normalization, o.normalization_version, + o.observed_at, + o.pref, o.ordinal, o.type_label, o.type_tokens, o.vcard_property, + o.vcard_group, o.vcard_prop_id, o.vcard_pid, o.vcard_altid, o.source, + o.source_ref, o.confidence, o.active_from, o.active_until, + o.created_at, o.updated_at, o.superseded_at + FROM participant_contact_observations o + LEFT JOIN communication_services cs ON cs.id = o.service_id` + +func getParticipantObservationTx( + ctx context.Context, tx *loggedTx, participantID, id int64, +) (*ParticipantContactObservation, error) { + observation, err := scanParticipantObservation(tx.QueryRowContext(ctx, + participantObservationSelect+` WHERE o.participant_id = ? AND o.id = ?`, + participantID, id, + )) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrProfileValueNotFound + } + return observation, err +} + +func scanParticipantObservation(row scanner) (*ParticipantContactObservation, error) { + var observation ParticipantContactObservation + var sourceID sql.NullInt64 + var serviceSlug, scopeKind, scopeValue, providerUserID sql.NullString + var observedAt sql.NullTime + var env profileEnvelopeScanValues + dest := []any{ + &observation.Envelope.ID, &observation.ParticipantID, &sourceID, + &observation.AddressKind, &serviceSlug, &scopeKind, &scopeValue, + &providerUserID, &observation.OriginalValue, &observation.NormalizedValue, + &observation.Normalization, &observation.NormalizationVersion, &observedAt, + } + dest = append(dest, env.destinations()...) + if err := row.Scan(dest...); err != nil { + return nil, err + } + observation.SourceID = nullInt64Ptr(sourceID) + observation.ServiceSlug = nullStringPtr(serviceSlug) + observation.ScopeKind = nullStringPtr(scopeKind) + observation.ScopeValue = nullStringPtr(scopeValue) + observation.ProviderUserID = nullStringPtr(providerUserID) + observation.ObservedAt = nullTimePtr(observedAt) + if err := env.apply(&observation.Envelope); err != nil { + return nil, err + } + return &observation, nil +} + +func int64Value(value *int64) any { + if value == nil { + return nil + } + return *value +} + +func nullInt64Ptr(value sql.NullInt64) *int64 { + if !value.Valid { + return nil + } + return &value.Int64 +} diff --git a/internal/store/participant_observations_test.go b/internal/store/participant_observations_test.go new file mode 100644 index 000000000..c32c29c33 --- /dev/null +++ b/internal/store/participant_observations_test.go @@ -0,0 +1,900 @@ +package store_test + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil/storetest" +) + +func TestObservationsAttachManyAddressesToOneParticipant(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + participantID, err := st.EnsureParticipantByIdentifier( + "beeper", "@alice:example.org", "Alice Example", + ) + require.NoError(err) + inputs := []store.ParticipantContactObservationInput{ + {AddressKind: store.ContactAddressPhone, ServiceSlug: new("whatsapp"), + ProviderUserID: new("wa-1"), OriginalValue: "+1 202 555 0123", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}}, + {AddressKind: store.ContactAddressEmail, ServiceSlug: new("google-chat"), + ProviderUserID: new("wa-1"), OriginalValue: "Alice@Example.com", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}}, + {AddressKind: store.ContactAddressUsername, ServiceSlug: new("slack"), + ScopeKind: new("workspace"), ScopeValue: new("T0EXAMPLE"), + ProviderUserID: new("wa-1"), OriginalValue: "Alice", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}}, + } + for _, input := range inputs { + result, err := st.RecordContactObservationContext(ctx, participantID, input) + require.NoError(err) + assert.True(result.Created) + assert.False(result.Conflicting) + } + observations, err := st.ListParticipantObservationsContext(ctx, participantID, true) + require.NoError(err) + assert.Len(observations, 3) +} + +func TestRecordingTheSameObservationTwiceIsIdempotent(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + participantID, err := st.EnsureParticipantByIdentifier( + "beeper", "@alice:example.org", "Alice Example", + ) + require.NoError(err) + input := store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressUsername, ServiceSlug: new("x"), + ProviderUserID: new("x-1"), OriginalValue: "@alice", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + } + first, err := st.RecordContactObservationContext(ctx, participantID, input) + require.NoError(err) + second, err := st.RecordContactObservationContext(ctx, participantID, input) + require.NoError(err) + assert.False(second.Created) + assert.Equal(first.Observation.Envelope.ID, second.Observation.Envelope.ID) +} + +func TestBlankProviderIDsAreAbsentAndDoNotSuppressConflicts(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := t.Context() + left, err := st.EnsureParticipantByIdentifier("example", "blank-provider-left", "Left") + require.NoError(err) + right, err := st.EnsureParticipantByIdentifier("example", "blank-provider-right", "Right") + require.NoError(err) + + input := store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressEmail, OriginalValue: "shared@example.org", + ProviderUserID: new(" "), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + } + leftResult, err := st.RecordContactObservationContext(ctx, left, input) + require.NoError(err) + assert.Nil(leftResult.Observation.ProviderUserID) + + input.ProviderUserID = new("\t") + rightResult, err := st.RecordContactObservationContext(ctx, right, input) + require.NoError(err) + assert.Nil(rightResult.Observation.ProviderUserID) + assert.True(rightResult.Created) + assert.True(rightResult.Conflicting) + + candidates, err := st.ListIdentityMatchCandidatesContext(ctx, nil, 10, 0) + require.NoError(err) + require.Len(candidates, 1) +} + +func TestContactObservationOrdinalsPreserveExplicitAndAppendMissingValues(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + participantID, err := st.EnsureParticipantByIdentifier( + "beeper", "@ordered:example.org", "Ordered Example", + ) + require.NoError(err) + explicitOrdinal := 7 + explicit, err := st.RecordContactObservationContext( + t.Context(), participantID, store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressEmail, + OriginalValue: "ordered@example.org", + Envelope: store.ValueEnvelopeInput{ + Source: store.ProvenanceArchiveObservation, Ordinal: &explicitOrdinal, + }, + }, + ) + require.NoError(err) + assert.Equal(explicitOrdinal, explicit.Observation.Envelope.Ordinal) + + appendedEmail, err := st.RecordContactObservationContext( + t.Context(), participantID, store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressEmail, + OriginalValue: "ordered.next@example.org", + Envelope: store.ValueEnvelopeInput{ + Source: store.ProvenanceArchiveObservation, + }, + }, + ) + require.NoError(err) + assert.Equal(explicitOrdinal+1, appendedEmail.Observation.Envelope.Ordinal) + require.NoError(st.SupersedeParticipantObservationContext( + t.Context(), participantID, appendedEmail.Observation.Envelope.ID, nil, + )) + + afterHistory, err := st.RecordContactObservationContext( + t.Context(), participantID, store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressEmail, + OriginalValue: "ordered.after-history@example.org", + Envelope: store.ValueEnvelopeInput{ + Source: store.ProvenanceArchiveObservation, + }, + }, + ) + require.NoError(err) + assert.Equal(explicitOrdinal+2, afterHistory.Observation.Envelope.Ordinal, + "append must not adopt a superseded observation's history slot") + + for index, phone := range []string{"+12025550101", "+12025550102"} { + result, err := st.RecordContactObservationContext( + t.Context(), participantID, store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressPhone, ServiceSlug: new("whatsapp"), + OriginalValue: phone, + Envelope: store.ValueEnvelopeInput{ + Source: store.ProvenanceArchiveObservation, + }, + }, + ) + require.NoError(err) + assert.Equal(index, result.Observation.Envelope.Ordinal) + } +} + +func TestRecordingTheSameObservationFromTwoSourcesKeepsBothProvenances(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + st := f.Store + ctx := t.Context() + participantID, err := st.EnsureParticipantByIdentifier( + "beeper", "@multi-source:example.org", "Multi Source", + ) + require.NoError(err) + otherSource, err := st.GetOrCreateSource("gmail", "other-source@example.org") + require.NoError(err) + + var ids []int64 + for _, sourceID := range []int64{f.Source.ID, otherSource.ID} { + result, err := st.RecordContactObservationContext( + ctx, participantID, store.ParticipantContactObservationInput{ + SourceID: &sourceID, AddressKind: store.ContactAddressEmail, + OriginalValue: "shared@example.org", + Envelope: store.ValueEnvelopeInput{ + Source: store.ProvenanceArchiveObservation, + }, + }, + ) + require.NoError(err) + assert.True(result.Created, "source %d", sourceID) + ids = append(ids, result.Observation.Envelope.ID) + } + assert.NotEqual(ids[0], ids[1]) + + require.NoError(st.RemoveSource(f.Source.ID)) + observations, err := st.ListParticipantObservationsContext(ctx, participantID, true) + require.NoError(err) + require.Len(observations, 1) + require.NotNil(observations[0].SourceID) + assert.Equal(otherSource.ID, *observations[0].SourceID) +} + +func TestMergeParticipantsPreservesContactObservations(t *testing.T) { + t.Run("repoints current and historical rows", func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + absorbed, err := st.EnsureParticipantByIdentifier( + "beeper", "@absorbed:example.org", "Absorbed", + ) + require.NoError(err) + survivor, err := st.EnsureParticipantByIdentifier( + "beeper", "@survivor:example.org", "Survivor", + ) + require.NoError(err) + + _, err = st.RecordContactObservationContext(ctx, absorbed, + store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressPhone, ServiceSlug: new("whatsapp"), + ProviderUserID: new("wa-absorbed"), OriginalValue: "+1 202 555 0142", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + }) + require.NoError(err) + historical, err := st.RecordContactObservationContext(ctx, absorbed, + store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressEmail, OriginalValue: "old@example.com", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + }) + require.NoError(err) + require.NoError(st.SupersedeParticipantObservationContext( + ctx, absorbed, historical.Observation.Envelope.ID, nil, + )) + + require.NoError(st.MergeParticipants(absorbed, survivor)) + current, err := st.ListParticipantObservationsContext(ctx, survivor, true) + require.NoError(err) + require.Len(current, 1) + assert.Equal("+12025550142", current[0].NormalizedValue) + all, err := st.ListParticipantObservationsContext(ctx, survivor, false) + require.NoError(err) + assert.Len(all, 2) + }) + + t.Run("deduplicates current rows and retains stable provider ID", func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + absorbed, err := st.EnsureParticipantByIdentifier( + "beeper", "@absorbed:example.org", "Absorbed", + ) + require.NoError(err) + survivor, err := st.EnsureParticipantByIdentifier( + "beeper", "@survivor:example.org", "Survivor", + ) + require.NoError(err) + + input := store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressUsername, ServiceSlug: new("x"), + OriginalValue: "@Shared", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + } + surviving, err := st.RecordContactObservationContext(ctx, survivor, input) + require.NoError(err) + input.OriginalValue = "shared" + input.ProviderUserID = new("x-stable-user") + absorbedResult, err := st.RecordContactObservationContext(ctx, absorbed, input) + require.NoError(err) + + require.NoError(st.MergeParticipants(absorbed, survivor)) + current, err := st.ListParticipantObservationsContext(ctx, survivor, true) + require.NoError(err) + require.Len(current, 1) + require.NotNil(current[0].ProviderUserID) + assert.Equal("x-stable-user", *current[0].ProviderUserID) + assert.Equal(surviving.Observation.Envelope.ID, current[0].Envelope.ID) + + all, err := st.ListParticipantObservationsContext(ctx, survivor, false) + require.NoError(err) + require.Len(all, 2) + var historical *store.ParticipantContactObservation + for index := range all { + if all[index].Envelope.ID == absorbedResult.Observation.Envelope.ID { + historical = &all[index] + } + } + require.NotNil(historical) + assert.Equal("shared", historical.OriginalValue) + assert.NotNil(historical.Envelope.ActiveUntil) + assert.NotNil(historical.Envelope.SupersededAt) + }) + + t.Run("keeps equal values from distinct sources", func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + st := f.Store + ctx := t.Context() + absorbed := f.EnsureParticipant("absorbed@example.com", "Absorbed", "example.com") + survivor := f.EnsureParticipant("survivor@example.com", "Survivor", "example.com") + otherSource, err := st.GetOrCreateSource("gmail", "merge-source@example.org") + require.NoError(err) + + for participantID, sourceID := range map[int64]int64{ + absorbed: otherSource.ID, + survivor: f.Source.ID, + } { + _, err := st.RecordContactObservationContext( + ctx, participantID, store.ParticipantContactObservationInput{ + SourceID: &sourceID, AddressKind: store.ContactAddressEmail, + OriginalValue: "shared@example.org", + Envelope: store.ValueEnvelopeInput{ + Source: store.ProvenanceArchiveObservation, + }, + }, + ) + require.NoError(err) + } + + require.NoError(st.MergeParticipants(absorbed, survivor)) + observations, err := st.ListParticipantObservationsContext(ctx, survivor, true) + require.NoError(err) + require.Len(observations, 2) + assert.ElementsMatch( + []int64{f.Source.ID, otherSource.ID}, + []int64{*observations[0].SourceID, *observations[1].SourceID}, + ) + }) +} + +func TestDuplicateUsernameUnderDifferentStableIDsBecomesAConflict(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + left, err := st.EnsureParticipantByIdentifier("beeper", "@alice:example.org", "Alice Example") + require.NoError(err) + right, err := st.EnsureParticipantByIdentifier("beeper", "@bob:example.org", "Bob Example") + require.NoError(err) + input := store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressUsername, ServiceSlug: new("x"), + OriginalValue: "@shared", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + } + input.ProviderUserID = new("x-left") + first, err := st.RecordContactObservationContext(ctx, left, input) + require.NoError(err) + assert.False(first.Conflicting) + input.ProviderUserID = new("x-right") + second, err := st.RecordContactObservationContext(ctx, right, input) + require.NoError(err) + assert.True(second.Conflicting) + assert.NotNil(second.CandidateID) + found, err := st.FindObservationsByAddressContext(ctx, store.ContactPointQuery{ + AddressKind: store.ContactAddressUsername, ServiceSlug: new("x"), + NormalizedValue: "shared", + }) + require.NoError(err) + assert.Len(found, 2) + candidates, err := st.ListIdentityMatchCandidatesContext( + ctx, []store.IdentityMatchState{store.IdentityMatchStateConflict}, 10, 0, + ) + require.NoError(err) + require.Len(candidates, 1) + assert.Equal(store.IdentityMatchServiceScopeUsername, candidates[0].Basis) +} + +func TestProviderIDEnrichmentRemovesGeneratedConflict(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := t.Context() + left, err := st.EnsureParticipantByIdentifier("example", "left", "Left") + require.NoError(err) + right, err := st.EnsureParticipantByIdentifier("example", "right", "Right") + require.NoError(err) + input := store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressEmail, OriginalValue: "shared@example.org", + ProviderUserID: new("provider-stable"), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + } + _, err = st.RecordContactObservationContext(ctx, left, input) + require.NoError(err) + input.ProviderUserID = nil + conflicting, err := st.RecordContactObservationContext(ctx, right, input) + require.NoError(err) + require.True(conflicting.Conflicting) + + input.ProviderUserID = new("provider-stable") + enriched, err := st.RecordContactObservationContext(ctx, right, input) + require.NoError(err) + assert.False(enriched.Created) + require.NotNil(enriched.Observation.ProviderUserID) + assert.Equal("provider-stable", *enriched.Observation.ProviderUserID) + + candidates, err := st.ListIdentityMatchCandidatesContext(ctx, nil, 10, 0) + require.NoError(err) + assert.Empty(candidates) +} + +func TestProviderIDEnrichmentChecksOtherParticipantProviderIDs(t *testing.T) { + for _, tt := range []struct { + name string + enrichedProvider string + wantConflict bool + wantCandidate bool + }{ + {name: "matching provider", enrichedProvider: "provider-left"}, + {name: "different provider", enrichedProvider: "provider-right", wantConflict: true, wantCandidate: true}, + } { + t.Run(tt.name, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := t.Context() + left, err := st.EnsureParticipantByIdentifier("example", "enrichment-left", "Left") + require.NoError(err) + right, err := st.EnsureParticipantByIdentifier("example", "enrichment-right", "Right") + require.NoError(err) + + input := store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressEmail, OriginalValue: "shared-enrichment@example.org", + ProviderUserID: new("provider-left"), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + } + _, err = st.RecordContactObservationContext(ctx, left, input) + require.NoError(err) + + input.ProviderUserID = nil + firstRight, err := st.RecordContactObservationContext(ctx, right, input) + require.NoError(err) + require.True(firstRight.Conflicting) + require.NotNil(firstRight.CandidateID) + + input.ProviderUserID = new(tt.enrichedProvider) + enriched, err := st.RecordContactObservationContext(ctx, right, input) + require.NoError(err) + assert.Equal(tt.wantConflict, enriched.Conflicting) + if tt.wantCandidate { + if assert.NotNil(enriched.CandidateID) { + assert.Equal(*firstRight.CandidateID, *enriched.CandidateID) + } + } else { + assert.Nil(enriched.CandidateID) + } + + candidates, err := st.ListIdentityMatchCandidatesContext(ctx, nil, 10, 0) + require.NoError(err) + if tt.wantCandidate { + assert.Len(candidates, 1) + } else { + assert.Empty(candidates) + } + }) + } +} + +func TestContradictoryProviderIDSupersedesCurrentObservation(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := t.Context() + participantID, err := st.EnsureParticipantByIdentifier( + "beeper", "@alice:example.org", "Alice Example", + ) + require.NoError(err) + input := store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressUsername, ServiceSlug: new("x"), + ProviderUserID: new("x-old"), OriginalValue: "@alice", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + } + first, err := st.RecordContactObservationContext(ctx, participantID, input) + require.NoError(err) + input.ProviderUserID = new("x-new") + second, err := st.RecordContactObservationContext(ctx, participantID, input) + require.NoError(err) + assert.True(second.Created, "a contradictory provider ID must record a new observation") + assert.NotEqual(first.Observation.Envelope.ID, second.Observation.Envelope.ID) + + current, err := st.ListParticipantObservationsContext(ctx, participantID, true) + require.NoError(err) + require.Len(current, 1) + require.NotNil(current[0].ProviderUserID) + assert.Equal("x-new", *current[0].ProviderUserID) + + all, err := st.ListParticipantObservationsContext(ctx, participantID, false) + require.NoError(err) + require.Len(all, 2, "the contradicted observation must be retained as history") + var historical *store.ParticipantContactObservation + for index := range all { + if all[index].Envelope.ID == first.Observation.Envelope.ID { + historical = &all[index] + } + } + require.NotNil(historical) + assert.NotNil(historical.Envelope.ActiveUntil) + assert.NotNil(historical.Envelope.SupersededAt) + require.NotNil(historical.ProviderUserID) + assert.Equal("x-old", *historical.ProviderUserID) +} + +func TestProviderIDChangeRemovesStaleGeneratedConflict(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := t.Context() + left, err := st.EnsureParticipantByIdentifier("example", "left-change", "Left") + require.NoError(err) + right, err := st.EnsureParticipantByIdentifier("example", "right-change", "Right") + require.NoError(err) + input := store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressEmail, OriginalValue: "shared@example.org", + ProviderUserID: new("provider-left"), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + } + _, err = st.RecordContactObservationContext(ctx, left, input) + require.NoError(err) + input.ProviderUserID = new("provider-right") + conflicting, err := st.RecordContactObservationContext(ctx, right, input) + require.NoError(err) + require.True(conflicting.Conflicting) + + input.ProviderUserID = new("provider-left") + converged, err := st.RecordContactObservationContext(ctx, right, input) + require.NoError(err) + assert.False(converged.Conflicting) + + candidates, err := st.ListIdentityMatchCandidatesContext(ctx, nil, 10, 0) + require.NoError(err) + assert.Empty(candidates, + "a generated conflict must not survive provider ID convergence") +} + +func TestMergeParticipantsRemovesConflictAfterProviderIDConvergence(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + ctx := t.Context() + left := f.EnsureParticipant("left@example.org", "Left", "example.org") + survivor := f.EnsureParticipant("survivor@example.org", "Survivor", "example.org") + absorbed := f.EnsureParticipant("absorbed@example.org", "Absorbed", "example.org") + otherSource, err := f.Store.GetOrCreateSource("gmail", "merge-convergence@example.org") + require.NoError(err) + + input := store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressEmail, OriginalValue: "shared@example.org", + ProviderUserID: new("provider-stable"), SourceID: &f.Source.ID, + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + } + _, err = f.Store.RecordContactObservationContext(ctx, left, input) + require.NoError(err) + input.SourceID = &otherSource.ID + input.ProviderUserID = nil + _, err = f.Store.RecordContactObservationContext(ctx, survivor, input) + require.NoError(err) + input.ProviderUserID = new("provider-stable") + _, err = f.Store.RecordContactObservationContext(ctx, absorbed, input) + require.NoError(err) + + candidates, err := f.Store.ListIdentityMatchCandidatesContext(ctx, nil, 10, 0) + require.NoError(err) + require.Len(candidates, 2) + + require.NoError(f.Store.MergeParticipants(absorbed, survivor)) + candidates, err = f.Store.ListIdentityMatchCandidatesContext(ctx, nil, 10, 0) + require.NoError(err) + assert.Empty(candidates) +} + +func TestCrossKindObservationPairDoesNotSupportGeneratedConflict(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := t.Context() + left, err := st.EnsureParticipantByIdentifier("example", "left-kind", "Left") + require.NoError(err) + right, err := st.EnsureParticipantByIdentifier("example", "right-kind", "Right") + require.NoError(err) + input := store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressUsername, OriginalValue: "alice", + ProviderUserID: new("provider-left"), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + } + _, err = st.RecordContactObservationContext(ctx, left, input) + require.NoError(err) + input.ProviderUserID = new("provider-right") + conflicting, err := st.RecordContactObservationContext(ctx, right, input) + require.NoError(err) + require.True(conflicting.Conflicting) + + input.AddressKind = store.ContactAddressSocial + social, err := st.RecordContactObservationContext(ctx, right, input) + require.NoError(err) + require.False(social.Conflicting, + "generation must not pair a social handle with a username") + + require.NoError(st.SupersedeParticipantObservationContext( + ctx, right, conflicting.Observation.Envelope.ID, nil, + )) + candidates, err := st.ListIdentityMatchCandidatesContext(ctx, nil, 10, 0) + require.NoError(err) + assert.Empty(candidates, + "a username conflict must not stay supported by a cross-kind social pair") +} + +func TestSameUsernameOnDifferentScopesIsNotAConflict(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + left, err := st.EnsureParticipantByIdentifier("beeper", "@alice:example.org", "Alice Example") + require.NoError(err) + right, err := st.EnsureParticipantByIdentifier("beeper", "@bob:example.org", "Bob Example") + require.NoError(err) + input := store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressUsername, ServiceSlug: new("slack"), + ScopeKind: new("workspace"), OriginalValue: "alice", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + } + input.ScopeValue, input.ProviderUserID = new("T0EXAMPLE"), new("slack-left") + _, err = st.RecordContactObservationContext(ctx, left, input) + require.NoError(err) + input.ScopeValue, input.ProviderUserID = new("T0OTHER"), new("slack-right") + result, err := st.RecordContactObservationContext(ctx, right, input) + require.NoError(err) + assert.False(result.Conflicting) +} + +func TestRenameSupersedesWithoutMovingHistoryBetweenParticipants(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + participantID, err := st.EnsureParticipantByIdentifier( + "beeper", "@alice:example.org", "Alice Example", + ) + require.NoError(err) + old, err := st.RecordContactObservationContext(ctx, participantID, store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressUsername, ServiceSlug: new("x"), + ProviderUserID: new("x-1"), OriginalValue: "@alice_old", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + }) + require.NoError(err) + require.NoError(st.SupersedeParticipantObservationContext( + ctx, participantID, old.Observation.Envelope.ID, nil, + )) + _, err = st.RecordContactObservationContext(ctx, participantID, store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressUsername, ServiceSlug: new("x"), + ProviderUserID: new("x-1"), OriginalValue: "@alice_new", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + }) + require.NoError(err) + current, err := st.ListParticipantObservationsContext(ctx, participantID, true) + require.NoError(err) + require.Len(current, 1) + assert.Equal("alice_new", current[0].NormalizedValue) + all, err := st.ListParticipantObservationsContext(ctx, participantID, false) + require.NoError(err) + assert.Len(all, 2) +} + +func TestSupersedeParticipantObservationRecomputesGeneratedConflicts(t *testing.T) { + t.Run("removes conflict after the last endpoint support is superseded", func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := t.Context() + left, err := st.EnsureParticipantByIdentifier("example", "left", "Left") + require.NoError(err) + right, err := st.EnsureParticipantByIdentifier("example", "right", "Right") + require.NoError(err) + + input := store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressSocial, OriginalValue: "social:shared", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + } + input.ProviderUserID = new("provider-left") + leftResult, err := st.RecordContactObservationContext(ctx, left, input) + require.NoError(err) + input.ProviderUserID = new("provider-right") + rightResult, err := st.RecordContactObservationContext(ctx, right, input) + require.NoError(err) + require.True(rightResult.Conflicting) + + require.NoError(st.SupersedeParticipantObservationContext( + ctx, left, leftResult.Observation.Envelope.ID, nil, + )) + candidates, err := st.ListIdentityMatchCandidatesContext(ctx, nil, 10, 0) + require.NoError(err) + assert.Empty(candidates) + }) + + t.Run("keeps conflict while another current observation supports the endpoint", func(t *testing.T) { + require := require.New(t) + fixture := storetest.New(t) + st := fixture.Store + ctx := t.Context() + left, err := st.EnsureParticipantByIdentifier("example", "left", "Left") + require.NoError(err) + right, err := st.EnsureParticipantByIdentifier("example", "right", "Right") + require.NoError(err) + + input := store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressURL, + OriginalValue: "https://example.org/shared", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + } + var leftObservationID int64 + for _, participantID := range []int64{left, left, right} { + result, recordErr := st.RecordContactObservationContext(ctx, participantID, input) + require.NoError(recordErr) + if participantID == left && leftObservationID == 0 { + leftObservationID = result.Observation.Envelope.ID + input.SourceID = &fixture.Source.ID + } + } + + require.NoError(st.SupersedeParticipantObservationContext( + ctx, left, leftObservationID, nil, + )) + candidates, err := st.ListIdentityMatchCandidatesContext(ctx, nil, 10, 0) + require.NoError(err) + require.Len(candidates, 1) + }) +} + +func TestUserConfirmedObservationConflictSurvivesSupportCleanup(t *testing.T) { + for _, test := range []struct { + name string + preexisting bool + }{ + {name: "generated"}, + {name: "promoted", preexisting: true}, + } { + t.Run(test.name, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := t.Context() + left, err := st.EnsureParticipantByIdentifier( + "example", "confirmed-conflict-left", "Left", + ) + require.NoError(err) + right, err := st.EnsureParticipantByIdentifier( + "example", "confirmed-conflict-right", "Right", + ) + require.NoError(err) + normalized := "confirmed@example.org" + if test.preexisting { + _, created, upsertErr := st.UpsertIdentityMatchCandidateContext( + ctx, store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchParticipant, LeftID: left, + RightKind: store.IdentityMatchParticipant, RightID: right, + Basis: store.IdentityMatchEmail, NormalizedValue: &normalized, + State: store.IdentityMatchStateCandidate, Source: store.ProvenanceUser, + }, + ) + require.NoError(upsertErr) + require.True(created) + } + + input := store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressEmail, OriginalValue: normalized, + ProviderUserID: new("provider-left"), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + } + leftResult, err := st.RecordContactObservationContext(ctx, left, input) + require.NoError(err) + input.ProviderUserID = new("provider-right") + rightResult, err := st.RecordContactObservationContext(ctx, right, input) + require.NoError(err) + require.True(rightResult.Conflicting) + require.NotNil(rightResult.CandidateID) + + note := "keep this conflict after review" + decided, err := st.DecideIdentityMatchCandidateContext( + ctx, *rightResult.CandidateID, store.IdentityMatchStateConflict, "user", ¬e, + ) + require.NoError(err) + assert.Equal(store.IdentityMatchStateConflict, decided.State) + assert.Equal(¬e, decided.Notes) + + require.NoError(st.SupersedeParticipantObservationContext( + ctx, left, leftResult.Observation.Envelope.ID, nil, + )) + candidates, err := st.ListIdentityMatchCandidatesContext(ctx, nil, 10, 0) + require.NoError(err) + require.Len(candidates, 1) + assert.Equal(decided.ID, candidates[0].ID) + assert.Equal(store.IdentityMatchStateConflict, candidates[0].State) + assert.Equal(new("user"), candidates[0].DecidedBy) + assert.Equal(¬e, candidates[0].Notes) + }) + } +} + +func TestSupersedeParticipantObservationDemotesPromotedCandidate(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := t.Context() + left, err := st.EnsureParticipantByIdentifier("example", "promoted-left", "Promoted Left") + require.NoError(err) + right, err := st.EnsureParticipantByIdentifier("example", "promoted-right", "Promoted Right") + require.NoError(err) + normalized := "shared@example.org" + sourceRef := "manual-review-import" + notes := "keep this review context" + candidate, created, err := st.UpsertIdentityMatchCandidateContext( + ctx, store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchParticipant, LeftID: left, + RightKind: store.IdentityMatchParticipant, RightID: right, + Basis: store.IdentityMatchEmail, NormalizedValue: &normalized, + State: store.IdentityMatchStateCandidate, Source: store.ProvenanceUser, + SourceRef: &sourceRef, Notes: ¬es, + }, + ) + require.NoError(err) + require.True(created) + _, err = st.AddIdentityMatchEvidenceContext(ctx, candidate.ID, store.IdentityMatchEvidenceInput{ + EvidenceKind: "manual_review", Detail: new("preserve this evidence"), + Source: store.ProvenanceUser, + }) + require.NoError(err) + + input := store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressEmail, OriginalValue: normalized, + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + } + input.ProviderUserID = new("provider-left") + leftResult, err := st.RecordContactObservationContext(ctx, left, input) + require.NoError(err) + input.ProviderUserID = new("provider-right") + rightResult, err := st.RecordContactObservationContext(ctx, right, input) + require.NoError(err) + require.True(rightResult.Conflicting) + + require.NoError(st.SupersedeParticipantObservationContext( + ctx, left, leftResult.Observation.Envelope.ID, nil, + )) + candidates, err := st.ListIdentityMatchCandidatesContext(ctx, nil, 10, 0) + require.NoError(err) + require.Len(candidates, 1) + assert.Equal(candidate.ID, candidates[0].ID) + assert.Equal(store.IdentityMatchStateCandidate, candidates[0].State) + assert.Equal(store.ProvenanceUser, candidates[0].Source) + assert.Equal(&sourceRef, candidates[0].SourceRef) + assert.Equal(¬es, candidates[0].Notes) + require.Len(candidates[0].Evidence, 1) + assert.Equal("manual_review", candidates[0].Evidence[0].EvidenceKind) +} + +func TestSupersedeParticipantObservationKeepsConflictsBetweenOtherParticipants(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + ctx := t.Context() + participants := []int64{ + f.EnsureParticipant("first@example.com", "First", "example.com"), + f.EnsureParticipant("second@example.com", "Second", "example.com"), + f.EnsureParticipant("third@example.com", "Third", "example.com"), + } + sources := []int64{f.Source.ID} + for _, identifier := range []string{"second-source@example.org", "third-source@example.org"} { + source, err := f.Store.GetOrCreateSource("gmail", identifier) + require.NoError(err) + sources = append(sources, source.ID) + } + + var firstObservationID int64 + for index, participantID := range participants { + result, err := f.Store.RecordContactObservationContext( + ctx, participantID, store.ParticipantContactObservationInput{ + SourceID: &sources[index], AddressKind: store.ContactAddressEmail, + ProviderUserID: new(fmt.Sprintf("provider-%d", index)), + OriginalValue: "shared@example.org", + Envelope: store.ValueEnvelopeInput{ + Source: store.ProvenanceArchiveObservation, + }, + }, + ) + require.NoError(err) + if index == 0 { + firstObservationID = result.Observation.Envelope.ID + } + } + candidates, err := f.Store.ListIdentityMatchCandidatesContext(ctx, nil, 10, 0) + require.NoError(err) + require.Len(candidates, 3, "three participants require a complete conflict graph") + + require.NoError(f.Store.SupersedeParticipantObservationContext( + ctx, participants[0], firstObservationID, nil, + )) + candidates, err = f.Store.ListIdentityMatchCandidatesContext(ctx, nil, 10, 0) + require.NoError(err) + require.Len(candidates, 1) + assert.ElementsMatch( + []int64{participants[1], participants[2]}, + []int64{candidates[0].LeftID, candidates[0].RightID}, + ) +} diff --git a/internal/store/person_addresses.go b/internal/store/person_addresses.go new file mode 100644 index 000000000..33a62de73 --- /dev/null +++ b/internal/store/person_addresses.go @@ -0,0 +1,297 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +type PersonAddressKind string + +const ( + PersonAddressPostal PersonAddressKind = "postal" + PersonAddressBirthPlace PersonAddressKind = "birth_place" + PersonAddressDeathPlace PersonAddressKind = "death_place" +) + +func (k PersonAddressKind) Valid() bool { + switch k { + case PersonAddressPostal, PersonAddressBirthPlace, PersonAddressDeathPlace: + return true + default: + return false + } +} + +type PersonAddress struct { + Envelope ValueEnvelope `json:"envelope"` + PersonID int64 `json:"person_id"` + AddressKind PersonAddressKind `json:"address_kind"` + PostOfficeBox *string `json:"post_office_box,omitempty"` + ExtendedAddress *string `json:"extended_address,omitempty"` + StreetAddress *string `json:"street_address,omitempty"` + Locality *string `json:"locality,omitempty"` + Region *string `json:"region,omitempty"` + PostalCode *string `json:"postal_code,omitempty"` + CountryName *string `json:"country_name,omitempty"` + ExtendedComponents *string `json:"extended_components,omitempty"` + FreeText *string `json:"free_text,omitempty"` + Label *string `json:"label,omitempty"` + GeoURI *string `json:"geo_uri,omitempty"` + Timezone *string `json:"timezone,omitempty"` + CountryCode *string `json:"country_code,omitempty"` + PlaceURI *string `json:"place_uri,omitempty"` + OriginalValue string `json:"original_value"` +} + +type PersonAddressInput struct { + AddressKind PersonAddressKind `json:"address_kind"` + PostOfficeBox *string `json:"post_office_box,omitempty"` + ExtendedAddress *string `json:"extended_address,omitempty"` + StreetAddress *string `json:"street_address,omitempty"` + Locality *string `json:"locality,omitempty"` + Region *string `json:"region,omitempty"` + PostalCode *string `json:"postal_code,omitempty"` + CountryName *string `json:"country_name,omitempty"` + ExtendedComponents *string `json:"extended_components,omitempty"` + FreeText *string `json:"free_text,omitempty"` + Label *string `json:"label,omitempty"` + GeoURI *string `json:"geo_uri,omitempty"` + Timezone *string `json:"timezone,omitempty"` + CountryCode *string `json:"country_code,omitempty"` + PlaceURI *string `json:"place_uri,omitempty"` + OriginalValue string `json:"original_value"` + Envelope ValueEnvelopeInput `json:"envelope"` +} + +var ( + ErrInvalidPersonAddressKind = errors.New("invalid person address kind") + ErrPersonAddressValueMissing = errors.New("person address requires at least one component") +) + +func (s *Store) AddPersonAddressContext( + ctx context.Context, personID int64, input PersonAddressInput, +) (*PersonAddress, error) { + var result *PersonAddress + err := s.withTxContext(ctx, func(tx *loggedTx) error { + if err := s.lockIdentityMutationTxContext(ctx, tx); err != nil { + return err + } + if err := ensureProfilePersonTx(ctx, tx, personID); err != nil { + return err + } + var err error + result, err = s.addPersonAddressTx(ctx, tx, personID, input) + if err != nil { + return err + } + if err := s.bumpPersonRevisionsTx(ctx, tx, personID); err != nil { + return err + } + return nil + }) + return result, err +} + +func (s *Store) ListPersonAddressesContext( + ctx context.Context, personID int64, currentOnly bool, +) ([]PersonAddress, error) { + var addresses []PersonAddress + err := s.withTxContext(ctx, func(tx *loggedTx) error { + var err error + addresses, err = s.listPersonAddressesTx(ctx, tx, personID, currentOnly) + return err + }) + return addresses, err +} + +func (s *Store) SupersedePersonAddressContext( + ctx context.Context, personID, addressID int64, activeUntil *time.Time, +) error { + return s.withTxContext(ctx, func(tx *loggedTx) error { + if err := s.lockIdentityMutationTxContext(ctx, tx); err != nil { + return err + } + if err := s.supersedePersonAddressTx( + ctx, tx, personID, addressID, activeUntil, + ); err != nil { + return err + } + return s.bumpPersonRevisionsTx(ctx, tx, personID) + }) +} + +func (s *Store) addPersonAddressTx( + ctx context.Context, tx *loggedTx, personID int64, input PersonAddressInput, +) (*PersonAddress, error) { + if !input.AddressKind.Valid() { + return nil, ErrInvalidPersonAddressKind + } + if !personAddressHasValue(input) { + return nil, ErrPersonAddressValueMissing + } + original := strings.TrimSpace(input.OriginalValue) + if original == "" { + original = personAddressOriginalValue(input) + } + env, err := resolveProfileEnvelopeTx( + ctx, tx, "person_addresses", "address_kind", + personID, input.AddressKind, input.Envelope, + ) + if err != nil { + return nil, err + } + args := []any{ + personID, input.AddressKind, stringValue(input.PostOfficeBox), + stringValue(input.ExtendedAddress), stringValue(input.StreetAddress), + stringValue(input.Locality), stringValue(input.Region), + stringValue(input.PostalCode), stringValue(input.CountryName), + stringValue(input.ExtendedComponents), stringValue(input.FreeText), + stringValue(input.Label), stringValue(input.GeoURI), + stringValue(input.Timezone), stringValue(input.CountryCode), + stringValue(input.PlaceURI), original, + } + args = append(args, profileEnvelopeArgs(env)...) + var id int64 + if err := tx.QueryRowContext(ctx, `INSERT INTO person_addresses ( + person_id, address_kind, post_office_box, extended_address, + street_address, locality, region, postal_code, country_name, + extended_components, free_text, label, geo_uri, timezone, + country_code, place_uri, original_value, `+profileEnvelopeWriteColumns+`, + created_at, updated_at + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + `+s.dialect.Now()+`, `+s.dialect.Now()+` + ) RETURNING id`, args...).Scan(&id); err != nil { + return nil, fmt.Errorf("add person address: %w", err) + } + return getPersonAddressTx(ctx, tx, personID, id) +} + +func personAddressOriginalValue(input PersonAddressInput) string { + postal := []*string{ + input.PostOfficeBox, input.ExtendedAddress, input.StreetAddress, + input.Locality, input.Region, input.PostalCode, input.CountryName, + } + hasPostal := false + components := make([]string, len(postal)) + for index, value := range postal { + components[index] = derefString(value) + hasPostal = hasPostal || strings.TrimSpace(components[index]) != "" + } + if hasPostal { + return strings.Join(components, ";") + } + for _, value := range []*string{ + input.FreeText, input.GeoURI, input.PlaceURI, input.ExtendedComponents, + input.Label, input.CountryCode, + } { + if value != nil && strings.TrimSpace(*value) != "" { + return strings.TrimSpace(*value) + } + } + return "" +} + +func (s *Store) listPersonAddressesTx( + ctx context.Context, tx *loggedTx, personID int64, currentOnly bool, +) ([]PersonAddress, error) { + query := personAddressSelect + ` WHERE person_id = ?` + if currentOnly { + query += ` AND active_until IS NULL AND superseded_at IS NULL` + } + query += ` ORDER BY address_kind, + CASE WHEN pref IS NULL THEN 1 ELSE 0 END, pref, ordinal, id` + return queryProfileRowsTx(ctx, tx, query, scanPersonAddress, personID) +} + +func (s *Store) supersedePersonAddressTx( + ctx context.Context, tx *loggedTx, personID, addressID int64, activeUntil *time.Time, +) error { + return s.supersedeProfileValueTx( + ctx, tx, "person_addresses", personID, addressID, activeUntil, + ) +} + +func personAddressHasValue(input PersonAddressInput) bool { + if strings.TrimSpace(input.OriginalValue) != "" { + return true + } + for _, value := range []*string{ + input.PostOfficeBox, input.ExtendedAddress, input.StreetAddress, + input.Locality, input.Region, input.PostalCode, input.CountryName, + input.ExtendedComponents, input.FreeText, input.PlaceURI, input.GeoURI, + input.Label, input.CountryCode, + } { + if value != nil && strings.TrimSpace(*value) != "" { + return true + } + } + return false +} + +func derefString(value *string) string { + if value == nil { + return "" + } + return *value +} + +const personAddressSelect = `SELECT + id, person_id, address_kind, post_office_box, extended_address, + street_address, locality, region, postal_code, country_name, + extended_components, free_text, label, geo_uri, timezone, country_code, + place_uri, original_value, ` + profileEnvelopeReadColumns + ` + FROM person_addresses` + +func getPersonAddressTx( + ctx context.Context, tx *loggedTx, personID, id int64, +) (*PersonAddress, error) { + address, err := scanPersonAddress(tx.QueryRowContext(ctx, + personAddressSelect+` WHERE person_id = ? AND id = ?`, personID, id, + )) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrProfileValueNotFound + } + return address, err +} + +func scanPersonAddress(row scanner) (*PersonAddress, error) { + var address PersonAddress + var postOfficeBox, extendedAddress, streetAddress, locality sql.NullString + var region, postalCode, countryName, extendedComponents sql.NullString + var freeText, label, geoURI, timezone, countryCode, placeURI sql.NullString + var env profileEnvelopeScanValues + dest := []any{ + &address.Envelope.ID, &address.PersonID, &address.AddressKind, + &postOfficeBox, &extendedAddress, &streetAddress, &locality, ®ion, + &postalCode, &countryName, &extendedComponents, &freeText, &label, + &geoURI, &timezone, &countryCode, &placeURI, &address.OriginalValue, + } + dest = append(dest, env.destinations()...) + if err := row.Scan(dest...); err != nil { + return nil, err + } + address.PostOfficeBox = nullStringPtr(postOfficeBox) + address.ExtendedAddress = nullStringPtr(extendedAddress) + address.StreetAddress = nullStringPtr(streetAddress) + address.Locality = nullStringPtr(locality) + address.Region = nullStringPtr(region) + address.PostalCode = nullStringPtr(postalCode) + address.CountryName = nullStringPtr(countryName) + address.ExtendedComponents = nullStringPtr(extendedComponents) + address.FreeText = nullStringPtr(freeText) + address.Label = nullStringPtr(label) + address.GeoURI = nullStringPtr(geoURI) + address.Timezone = nullStringPtr(timezone) + address.CountryCode = nullStringPtr(countryCode) + address.PlaceURI = nullStringPtr(placeURI) + if err := env.apply(&address.Envelope); err != nil { + return nil, err + } + return &address, nil +} diff --git a/internal/store/person_addresses_test.go b/internal/store/person_addresses_test.go new file mode 100644 index 000000000..241e23c18 --- /dev/null +++ b/internal/store/person_addresses_test.go @@ -0,0 +1,184 @@ +package store_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil/storetest" +) + +func TestPersonAddressRoundTripsStructuredComponentsAndMetadata(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + personID := newTestPerson(t, st) + + address, err := st.AddPersonAddressContext(ctx, personID, store.PersonAddressInput{ + AddressKind: store.PersonAddressPostal, PostOfficeBox: new("PO Box 42"), + ExtendedAddress: new("Suite 3"), StreetAddress: new("123 Example St."), + Locality: new("Exampleville"), Region: new("CA"), PostalCode: new("90000"), + CountryName: new("United States"), + ExtendedComponents: new("Room 5;Apt 2;Floor 3;123;Example St.;;;;;"), + Label: new("Home\nExampleville"), GeoURI: new("geo:37.386,-122.084"), + Timezone: new("America/Los_Angeles"), CountryCode: new("US"), + OriginalValue: "PO Box 42;Suite 3;123 Example St.;Exampleville;CA;90000;United States", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceVCardImport, + SourceRef: new("resource-1"), Pref: new(1), + TypeTokens: []string{"home"}, VCard: store.VCardIdentity{ + Property: "ADR", PropID: new("a1"), Group: new("item1"), + }}, + }) + require.NoError(err) + assert.Equal("123 Example St.", *address.StreetAddress) + assert.Equal("geo:37.386,-122.084", *address.GeoURI) + stored, err := st.ListPersonAddressesContext(ctx, personID, true) + require.NoError(err) + require.Len(stored, 1) + assert.Equal("a1", *stored[0].Envelope.VCard.PropID) +} + +func TestBirthAndDeathPlacesAreAddressRows(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + personID := newTestPerson(t, st) + _, err := st.AddPersonAddressContext(ctx, personID, store.PersonAddressInput{ + AddressKind: store.PersonAddressBirthPlace, FreeText: new("Exampleville, CA"), + OriginalValue: "Exampleville, CA", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceVCardImport, + VCard: store.VCardIdentity{Property: "BIRTHPLACE"}}, + }) + require.NoError(err) + _, err = st.AddPersonAddressContext(ctx, personID, store.PersonAddressInput{ + AddressKind: store.PersonAddressDeathPlace, PlaceURI: new("geo:37.386,-122.084"), + OriginalValue: "geo:37.386,-122.084", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceVCardImport, + VCard: store.VCardIdentity{Property: "DEATHPLACE"}}, + }) + require.NoError(err) + addresses, err := st.ListPersonAddressesContext(ctx, personID, true) + require.NoError(err) + require.Len(addresses, 2) + assert.Equal(store.PersonAddressBirthPlace, addresses[0].AddressKind) + assert.Equal(store.PersonAddressDeathPlace, addresses[1].AddressKind) +} + +func TestPersonAddressDerivesOriginalValueFromAlternateRepresentation(t *testing.T) { + for _, test := range []struct { + name string + value string + apply func(*store.PersonAddressInput, *string) + check func(*testing.T, *store.PersonAddress) + }{ + { + name: "free text", value: "Exampleville, CA", + apply: func(input *store.PersonAddressInput, value *string) { + input.FreeText = value + }, + }, + { + name: "geo URI", value: "geo:37.386,-122.084", + apply: func(input *store.PersonAddressInput, value *string) { + input.GeoURI = value + }, + }, + { + name: "place URI", value: "https://example.invalid/places/42", + apply: func(input *store.PersonAddressInput, value *string) { + input.PlaceURI = value + }, + }, + { + name: "label", value: "Home address", + apply: func(input *store.PersonAddressInput, value *string) { + input.Label = value + }, + check: func(t *testing.T, address *store.PersonAddress) { + t.Helper() + require.NotNil(t, address.Label) + assert.Equal(t, "Home address", *address.Label) + assert.Nil(t, address.CountryCode) + }, + }, + { + name: "country code", value: "US", + apply: func(input *store.PersonAddressInput, value *string) { + input.CountryCode = value + }, + check: func(t *testing.T, address *store.PersonAddress) { + t.Helper() + require.NotNil(t, address.CountryCode) + assert.Equal(t, "US", *address.CountryCode) + assert.Nil(t, address.Label) + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + require := require.New(t) + st := storetest.New(t).Store + input := store.PersonAddressInput{ + AddressKind: store.PersonAddressBirthPlace, + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + } + test.apply(&input, &test.value) + + personID := newTestPerson(t, st) + address, err := st.AddPersonAddressContext( + t.Context(), personID, input, + ) + require.NoError(err) + require.Equal(test.value, address.OriginalValue) + if test.check != nil { + test.check(t, address) + } + stored, err := st.ListPersonAddressesContext(t.Context(), personID, true) + require.NoError(err) + require.Len(stored, 1) + require.Equal(test.value, stored[0].OriginalValue) + if test.check != nil { + test.check(t, &stored[0]) + } + }) + } +} + +func TestPersonAddressValidationAndSupersession(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + personID := newTestPerson(t, st) + _, err := st.AddPersonAddressContext(ctx, personID, store.PersonAddressInput{ + AddressKind: "billing", StreetAddress: new("123 Example St."), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.ErrorIs(err, store.ErrInvalidPersonAddressKind) + _, err = st.AddPersonAddressContext(ctx, personID, store.PersonAddressInput{ + AddressKind: store.PersonAddressPostal, + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.ErrorIs(err, store.ErrPersonAddressValueMissing) + for _, input := range []store.PersonAddressInput{ + {AddressKind: store.PersonAddressPostal, Label: new(" \t\n "), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}}, + {AddressKind: store.PersonAddressPostal, CountryCode: new(" \t\n "), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}}, + } { + _, err = st.AddPersonAddressContext(ctx, personID, input) + require.ErrorIs(err, store.ErrPersonAddressValueMissing) + } + address, err := st.AddPersonAddressContext(ctx, personID, store.PersonAddressInput{ + AddressKind: store.PersonAddressPostal, StreetAddress: new("123 Example St."), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(err) + require.NoError(st.SupersedePersonAddressContext(ctx, personID, address.Envelope.ID, nil)) + current, err := st.ListPersonAddressesContext(ctx, personID, true) + require.NoError(err) + assert.Empty(current) +} diff --git a/internal/store/person_categories.go b/internal/store/person_categories.go new file mode 100644 index 000000000..7a290591c --- /dev/null +++ b/internal/store/person_categories.go @@ -0,0 +1,173 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +type PersonCategory struct { + Envelope ValueEnvelope `json:"envelope"` + PersonID int64 `json:"person_id"` + OriginalValue string `json:"original_value"` + NormalizedValue string `json:"normalized_value"` +} + +type PersonCategoryInput struct { + OriginalValue string `json:"original_value"` + Envelope ValueEnvelopeInput `json:"envelope"` +} + +var ( + ErrPersonCategoryDuplicate = errors.New("person already has this current category") + ErrPersonCategoryEmpty = errors.New("person category must be non-empty") +) + +func (s *Store) AddPersonCategoryContext( + ctx context.Context, personID int64, input PersonCategoryInput, +) (*PersonCategory, error) { + var result *PersonCategory + err := s.withTxContext(ctx, func(tx *loggedTx) error { + if err := s.lockIdentityMutationTxContext(ctx, tx); err != nil { + return err + } + if err := ensureProfilePersonTx(ctx, tx, personID); err != nil { + return err + } + var err error + result, err = s.addPersonCategoryTx(ctx, tx, personID, input) + if err != nil { + return err + } + if err := s.bumpPersonRevisionsTx(ctx, tx, personID); err != nil { + return err + } + return nil + }) + return result, err +} + +func (s *Store) ListPersonCategoriesContext( + ctx context.Context, personID int64, currentOnly bool, +) ([]PersonCategory, error) { + var categories []PersonCategory + err := s.withTxContext(ctx, func(tx *loggedTx) error { + var err error + categories, err = s.listPersonCategoriesTx(ctx, tx, personID, currentOnly) + return err + }) + return categories, err +} + +func (s *Store) SupersedePersonCategoryContext( + ctx context.Context, personID, categoryID int64, activeUntil *time.Time, +) error { + return s.withTxContext(ctx, func(tx *loggedTx) error { + if err := s.lockIdentityMutationTxContext(ctx, tx); err != nil { + return err + } + if err := s.supersedePersonCategoryTx( + ctx, tx, personID, categoryID, activeUntil, + ); err != nil { + return err + } + return s.bumpPersonRevisionsTx(ctx, tx, personID) + }) +} + +func (s *Store) addPersonCategoryTx( + ctx context.Context, tx *loggedTx, personID int64, input PersonCategoryInput, +) (*PersonCategory, error) { + original := strings.TrimSpace(input.OriginalValue) + if original == "" { + return nil, ErrPersonCategoryEmpty + } + normalized := strings.ToLower(original) + var duplicate int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM person_categories + WHERE person_id = ? AND normalized_value = ? + AND active_until IS NULL AND superseded_at IS NULL`, + personID, normalized, + ).Scan(&duplicate); err != nil { + return nil, fmt.Errorf("check person category: %w", err) + } + if duplicate > 0 { + return nil, ErrPersonCategoryDuplicate + } + env, err := resolveProfileEnvelopeTx( + ctx, tx, "person_categories", "", personID, nil, input.Envelope, + ) + if err != nil { + return nil, err + } + args := []any{personID, original, normalized} + args = append(args, profileEnvelopeArgs(env)...) + var id int64 + if err := tx.QueryRowContext(ctx, `INSERT INTO person_categories ( + person_id, original_value, normalized_value, `+profileEnvelopeWriteColumns+`, + created_at, updated_at + ) VALUES ( + ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + `+s.dialect.Now()+`, `+s.dialect.Now()+` + ) RETURNING id`, args...).Scan(&id); err != nil { + return nil, fmt.Errorf("add person category: %w", err) + } + return getPersonCategoryTx(ctx, tx, personID, id) +} + +func (s *Store) listPersonCategoriesTx( + ctx context.Context, tx *loggedTx, personID int64, currentOnly bool, +) ([]PersonCategory, error) { + query := personCategorySelect + ` WHERE person_id = ?` + if currentOnly { + query += ` AND active_until IS NULL AND superseded_at IS NULL` + } + query += ` ORDER BY normalized_value, + CASE WHEN pref IS NULL THEN 1 ELSE 0 END, pref, ordinal, id` + return queryProfileRowsTx(ctx, tx, query, scanPersonCategory, personID) +} + +func (s *Store) supersedePersonCategoryTx( + ctx context.Context, tx *loggedTx, personID, categoryID int64, activeUntil *time.Time, +) error { + return s.supersedeProfileValueTx( + ctx, tx, "person_categories", personID, categoryID, activeUntil, + ) +} + +const personCategorySelect = `SELECT + id, person_id, original_value, normalized_value, ` + profileEnvelopeReadColumns + ` + FROM person_categories` + +func getPersonCategoryTx( + ctx context.Context, tx *loggedTx, personID, id int64, +) (*PersonCategory, error) { + category, err := scanPersonCategory(tx.QueryRowContext(ctx, + personCategorySelect+` WHERE person_id = ? AND id = ?`, personID, id, + )) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrProfileValueNotFound + } + return category, err +} + +func scanPersonCategory(row scanner) (*PersonCategory, error) { + var category PersonCategory + var env profileEnvelopeScanValues + dest := []any{ + &category.Envelope.ID, &category.PersonID, + &category.OriginalValue, &category.NormalizedValue, + } + dest = append(dest, env.destinations()...) + if err := row.Scan(dest...); err != nil { + return nil, err + } + if err := env.apply(&category.Envelope); err != nil { + return nil, err + } + return &category, nil +} diff --git a/internal/store/person_categories_test.go b/internal/store/person_categories_test.go new file mode 100644 index 000000000..4fa5170e8 --- /dev/null +++ b/internal/store/person_categories_test.go @@ -0,0 +1,51 @@ +package store_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil/storetest" +) + +func TestPersonCategoriesAreOneRowPerTagWithHistory(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + personID := newTestPerson(t, st) + for _, tag := range []string{"Friends", "Book Club"} { + _, err := st.AddPersonCategoryContext(ctx, personID, store.PersonCategoryInput{ + OriginalValue: tag, Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceVCardImport}, + }) + require.NoError(err) + } + _, err := st.AddPersonCategoryContext(ctx, personID, store.PersonCategoryInput{ + OriginalValue: "friends", Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.ErrorIs(err, store.ErrPersonCategoryDuplicate) + categories, err := st.ListPersonCategoriesContext(ctx, personID, true) + require.NoError(err) + require.Len(categories, 2) + require.NoError(st.SupersedePersonCategoryContext(ctx, personID, categories[1].Envelope.ID, nil)) + _, err = st.AddPersonCategoryContext(ctx, personID, store.PersonCategoryInput{ + OriginalValue: categories[1].OriginalValue, + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(err) + all, err := st.ListPersonCategoriesContext(ctx, personID, false) + require.NoError(err) + assert.Len(all, 3) +} + +func TestAddPersonCategoryRejectsBlankValue(t *testing.T) { + require := require.New(t) + st := storetest.New(t).Store + personID := newTestPerson(t, st) + _, err := st.AddPersonCategoryContext(context.Background(), personID, store.PersonCategoryInput{ + OriginalValue: " ", Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.ErrorIs(err, store.ErrPersonCategoryEmpty) +} diff --git a/internal/store/person_contact_points.go b/internal/store/person_contact_points.go new file mode 100644 index 000000000..77a6a3a6d --- /dev/null +++ b/internal/store/person_contact_points.go @@ -0,0 +1,332 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +type PersonContactPoint struct { + Envelope ValueEnvelope `json:"envelope"` + PersonID int64 `json:"person_id"` + AddressKind ContactAddressKind `json:"address_kind"` + ServiceSlug *string `json:"service_slug,omitempty"` + ScopeKind *string `json:"scope_kind,omitempty"` + ScopeValue *string `json:"scope_value,omitempty"` + OriginalValue string `json:"original_value"` + NormalizedValue string `json:"normalized_value"` + Normalization string `json:"normalization"` + NormalizationVersion int `json:"normalization_version"` + URI *string `json:"uri,omitempty"` +} + +type PersonContactPointInput struct { + AddressKind ContactAddressKind `json:"address_kind"` + ServiceSlug *string `json:"service_slug,omitempty"` + ScopeKind *string `json:"scope_kind,omitempty"` + ScopeValue *string `json:"scope_value,omitempty"` + OriginalValue string `json:"original_value"` + URI *string `json:"uri,omitempty"` + Envelope ValueEnvelopeInput `json:"envelope"` +} + +type ContactPointQuery struct { + AddressKind ContactAddressKind + ServiceSlug *string + ScopeKind *string + ScopeValue *string + NormalizedValue string +} + +var ( + ErrInvalidContactAddressKind = errors.New("invalid contact address kind") + ErrContactPointValueMissing = errors.New("contact point requires a non-empty value") +) + +func (s *Store) AddPersonContactPointContext( + ctx context.Context, personID int64, input PersonContactPointInput, +) (*PersonContactPoint, error) { + var result *PersonContactPoint + err := s.withTxContext(ctx, func(tx *loggedTx) error { + if err := s.lockIdentityMutationTxContext(ctx, tx); err != nil { + return err + } + if err := ensureProfilePersonTx(ctx, tx, personID); err != nil { + return err + } + var err error + result, err = s.addPersonContactPointTx(ctx, tx, personID, input) + if err != nil { + return err + } + if err := s.bumpPersonRevisionsTx(ctx, tx, personID); err != nil { + return err + } + return nil + }) + return result, err +} + +func (s *Store) ListPersonContactPointsContext( + ctx context.Context, personID int64, currentOnly bool, +) ([]PersonContactPoint, error) { + var points []PersonContactPoint + err := s.withTxContext(ctx, func(tx *loggedTx) error { + var err error + points, err = s.listPersonContactPointsTx(ctx, tx, personID, currentOnly) + return err + }) + return points, err +} + +func (s *Store) FindPersonContactPointsContext( + ctx context.Context, query ContactPointQuery, +) ([]PersonContactPoint, error) { + if !query.AddressKind.Valid() { + return nil, ErrInvalidContactAddressKind + } + query.ScopeKind = trimmedOrNil(query.ScopeKind) + query.ScopeValue = trimmedOrNil(query.ScopeValue) + service, hasService, err := s.resolveOptionalCommunicationServiceContext(ctx, query.ServiceSlug) + if err != nil { + return nil, err + } + var serviceID any + if hasService { + serviceID = service.ID + } + rows, err := s.db.QueryContext(ctx, personContactPointSelect+` + WHERE p.address_kind = ? + AND (p.service_id = ? OR (p.service_id IS NULL AND CAST(? AS BIGINT) IS NULL)) + AND (p.scope_kind = ? OR (p.scope_kind IS NULL AND CAST(? AS TEXT) IS NULL)) + AND (p.scope_value = ? OR (p.scope_value IS NULL AND CAST(? AS TEXT) IS NULL)) + AND p.normalized_value = ? + AND p.active_until IS NULL AND p.superseded_at IS NULL + ORDER BY p.person_id, p.id`, + query.AddressKind, + serviceID, serviceID, + stringValue(query.ScopeKind), stringValue(query.ScopeKind), + stringValue(query.ScopeValue), stringValue(query.ScopeValue), + query.NormalizedValue, + ) + if err != nil { + return nil, fmt.Errorf("find person contact points: %w", err) + } + defer func() { _ = rows.Close() }() + points := make([]PersonContactPoint, 0) + for rows.Next() { + point, err := scanPersonContactPoint(rows) + if err != nil { + return nil, fmt.Errorf("scan person contact point: %w", err) + } + points = append(points, *point) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("find person contact points: %w", err) + } + return points, nil +} + +func (s *Store) SupersedePersonContactPointContext( + ctx context.Context, personID, contactPointID int64, activeUntil *time.Time, +) error { + return s.withTxContext(ctx, func(tx *loggedTx) error { + if err := s.lockIdentityMutationTxContext(ctx, tx); err != nil { + return err + } + if err := s.supersedePersonContactPointTx( + ctx, tx, personID, contactPointID, activeUntil, + ); err != nil { + return err + } + return s.bumpPersonRevisionsTx(ctx, tx, personID) + }) +} + +func (s *Store) addPersonContactPointTx( + ctx context.Context, + tx *loggedTx, + personID int64, + input PersonContactPointInput, +) (*PersonContactPoint, error) { + if !input.AddressKind.Valid() { + return nil, ErrInvalidContactAddressKind + } + if strings.TrimSpace(input.OriginalValue) == "" { + return nil, ErrContactPointValueMissing + } + input.ScopeKind = trimmedOrNil(input.ScopeKind) + input.ScopeValue = trimmedOrNil(input.ScopeValue) + service, hasService, err := resolveCommunicationServiceTx(ctx, tx, input.ServiceSlug) + if err != nil { + return nil, err + } + if err := ValidateServiceScope(service, input.ScopeKind, input.ScopeValue); err != nil { + return nil, err + } + normalized, err := NormalizeServiceValue(service, input.AddressKind, input.OriginalValue) + if err != nil { + return nil, err + } + normalization := fallbackContactNormalization(input.AddressKind) + version := 1 + var serviceID any + if hasService { + serviceID, normalization, version = service.ID, service.Normalization, service.NormalizationVersion + } + env, err := resolveProfileEnvelopeTx( + ctx, tx, "person_contact_points", "address_kind", + personID, input.AddressKind, input.Envelope, + ) + if err != nil { + return nil, err + } + args := []any{ + personID, input.AddressKind, serviceID, stringValue(input.ScopeKind), + stringValue(input.ScopeValue), input.OriginalValue, normalized, + normalization, version, stringValue(input.URI), + } + args = append(args, profileEnvelopeArgs(env)...) + var id int64 + if err := tx.QueryRowContext(ctx, `INSERT INTO person_contact_points ( + person_id, address_kind, service_id, scope_kind, scope_value, + original_value, normalized_value, normalization, + normalization_version, uri, `+profileEnvelopeWriteColumns+`, + created_at, updated_at + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + `+s.dialect.Now()+`, `+s.dialect.Now()+` + ) RETURNING id`, args...).Scan(&id); err != nil { + return nil, fmt.Errorf( + "add person contact point property=%q prop_id=%v: %w", + env.VCard.Property, env.VCard.PropID, err, + ) + } + return getPersonContactPointTx(ctx, tx, personID, id) +} + +func (s *Store) listPersonContactPointsTx( + ctx context.Context, tx *loggedTx, personID int64, currentOnly bool, +) ([]PersonContactPoint, error) { + query := personContactPointSelect + ` WHERE p.person_id = ?` + if currentOnly { + query += ` AND p.active_until IS NULL AND p.superseded_at IS NULL` + } + query += ` ORDER BY p.address_kind, + CASE WHEN p.pref IS NULL THEN 1 ELSE 0 END, p.pref, p.ordinal, p.id` + return queryProfileRowsTx(ctx, tx, query, scanPersonContactPoint, personID) +} + +func (s *Store) supersedePersonContactPointTx( + ctx context.Context, + tx *loggedTx, + personID, contactPointID int64, + activeUntil *time.Time, +) error { + return s.supersedeProfileValueTx( + ctx, tx, "person_contact_points", personID, contactPointID, activeUntil, + ) +} + +func fallbackContactNormalization(kind ContactAddressKind) string { + switch kind { + case ContactAddressEmail: + return NormalizationEmail + case ContactAddressPhone: + return NormalizationPhoneE164 + case ContactAddressLanguage: + return NormalizationLower + default: + return NormalizationNone + } +} + +func (s *Store) resolveOptionalCommunicationServiceContext( + ctx context.Context, slug *string, +) (*CommunicationService, bool, error) { + if slug == nil || strings.TrimSpace(*slug) == "" { + return nil, false, nil + } + service, err := s.ResolveCommunicationServiceContext(ctx, *slug) + if err != nil { + return nil, false, err + } + return service, true, nil +} + +func resolveCommunicationServiceTx( + ctx context.Context, tx *loggedTx, slug *string, +) (*CommunicationService, bool, error) { + if slug == nil || strings.TrimSpace(*slug) == "" { + return nil, false, nil + } + lookup := strings.ToLower(strings.TrimSpace(*slug)) + service, err := scanCommunicationService(tx.QueryRowContext(ctx, serviceSelect+` + WHERE slug = ? OR id = ( + SELECT service_id FROM communication_service_aliases WHERE alias = ? + ) + ORDER BY CASE WHEN slug = ? THEN 0 ELSE 1 END + LIMIT 1`, lookup, lookup, lookup)) + if errors.Is(err, sql.ErrNoRows) { + return nil, false, ErrServiceNotFound + } + if err != nil { + return nil, false, err + } + service.Aliases, err = loadServiceAliasesTx(ctx, tx, service.ID) + return service, true, err +} + +const personContactPointSelect = `SELECT + p.id, p.person_id, p.address_kind, cs.slug, p.scope_kind, p.scope_value, + p.original_value, p.normalized_value, p.normalization, + p.normalization_version, p.uri, + p.pref, p.ordinal, p.type_label, p.type_tokens, p.vcard_property, + p.vcard_group, p.vcard_prop_id, p.vcard_pid, p.vcard_altid, p.source, + p.source_ref, p.confidence, p.active_from, p.active_until, + p.created_at, p.updated_at, p.superseded_at + FROM person_contact_points p + LEFT JOIN communication_services cs ON cs.id = p.service_id` + +func getPersonContactPointTx( + ctx context.Context, tx *loggedTx, personID, id int64, +) (*PersonContactPoint, error) { + point, err := scanPersonContactPoint(tx.QueryRowContext(ctx, + personContactPointSelect+` WHERE p.person_id = ? AND p.id = ?`, + personID, id, + )) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrProfileValueNotFound + } + if err != nil { + return nil, fmt.Errorf("get person contact point: %w", err) + } + return point, nil +} + +func scanPersonContactPoint(row scanner) (*PersonContactPoint, error) { + var point PersonContactPoint + var serviceSlug, scopeKind, scopeValue, uri sql.NullString + var env profileEnvelopeScanValues + dest := []any{ + &point.Envelope.ID, &point.PersonID, &point.AddressKind, &serviceSlug, + &scopeKind, &scopeValue, &point.OriginalValue, &point.NormalizedValue, + &point.Normalization, &point.NormalizationVersion, &uri, + } + dest = append(dest, env.destinations()...) + if err := row.Scan(dest...); err != nil { + return nil, err + } + point.ServiceSlug = nullStringPtr(serviceSlug) + point.ScopeKind = nullStringPtr(scopeKind) + point.ScopeValue = nullStringPtr(scopeValue) + point.URI = nullStringPtr(uri) + if err := env.apply(&point.Envelope); err != nil { + return nil, err + } + return &point, nil +} diff --git a/internal/store/person_contact_points_test.go b/internal/store/person_contact_points_test.go new file mode 100644 index 000000000..7d5e84e0d --- /dev/null +++ b/internal/store/person_contact_points_test.go @@ -0,0 +1,123 @@ +package store_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil/storetest" +) + +func TestPersonKeepsManyCurrentAndHistoricalContactPointsWithoutJSONBundling(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + personID := newTestPerson(t, st) + + inputs := []store.PersonContactPointInput{ + {AddressKind: store.ContactAddressPhone, ServiceSlug: new("whatsapp"), + OriginalValue: "+1 (202) 555-0123", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser, Pref: new(1)}}, + {AddressKind: store.ContactAddressEmail, OriginalValue: "Alice@Example.com", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}}, + {AddressKind: store.ContactAddressUsername, ServiceSlug: new("slack"), + ScopeKind: new("workspace"), ScopeValue: new("T0EXAMPLE"), + OriginalValue: "Alice", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}}, + } + created := make([]store.PersonContactPoint, 0, len(inputs)) + for _, input := range inputs { + point, err := st.AddPersonContactPointContext(ctx, personID, input) + require.NoError(err) + created = append(created, *point) + } + assert.Equal("+12025550123", created[0].NormalizedValue) + assert.Equal("alice@example.com", created[1].NormalizedValue) + assert.Equal("alice", created[2].NormalizedValue) + require.NoError(st.SupersedePersonContactPointContext(ctx, personID, created[0].Envelope.ID, nil)) + current, err := st.ListPersonContactPointsContext(ctx, personID, true) + require.NoError(err) + assert.Len(current, 2) + all, err := st.ListPersonContactPointsContext(ctx, personID, false) + require.NoError(err) + assert.Len(all, 3) +} + +func TestSameUsernameIsSafeAcrossServicesAndScopes(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + personID := newTestPerson(t, st) + + for _, scope := range []string{"irc.example.net", "irc.example.org"} { + _, err := st.AddPersonContactPointContext(ctx, personID, store.PersonContactPointInput{ + AddressKind: store.ContactAddressUsername, ServiceSlug: new("irc"), + ScopeKind: new("network"), ScopeValue: new(scope), OriginalValue: "alice", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(err) + } + found, err := st.FindPersonContactPointsContext(ctx, store.ContactPointQuery{ + AddressKind: store.ContactAddressUsername, ServiceSlug: new("irc"), + ScopeKind: new("network"), ScopeValue: new("irc.example.net"), + NormalizedValue: "alice", + }) + require.NoError(err) + require.Len(found, 1) + assert.Equal("irc.example.net", *found[0].ScopeValue) +} + +func TestContactPointScopeKindAndLanguageValidation(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + personID := newTestPerson(t, st) + + _, err := st.AddPersonContactPointContext(ctx, personID, store.PersonContactPointInput{ + AddressKind: store.ContactAddressUsername, ServiceSlug: new("slack"), + OriginalValue: "alice", Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.ErrorIs(err, store.ErrServiceScopeRequired) + _, err = st.AddPersonContactPointContext(ctx, personID, store.PersonContactPointInput{ + AddressKind: "pager", OriginalValue: "12345", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.ErrorIs(err, store.ErrInvalidContactAddressKind) + point, err := st.AddPersonContactPointContext(ctx, personID, store.PersonContactPointInput{ + AddressKind: store.ContactAddressLanguage, OriginalValue: "en-GB", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceVCardImport, + VCard: store.VCardIdentity{Property: "LANGUAGE"}}, + }) + require.NoError(err) + assert.Equal("en-gb", point.NormalizedValue) + assert.Equal("en-GB", point.OriginalValue) +} + +func TestRetractedContactPointLeavesCurrentSetButStaysInHistory(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + personID := newTestPerson(t, st) + _, err := st.AddPersonContactPointContext(ctx, personID, store.PersonContactPointInput{ + AddressKind: store.ContactAddressUsername, ServiceSlug: new("x"), + OriginalValue: "@alice", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceExtraction, Confidence: new(0.6)}, + }) + require.NoError(err) + _, err = st.DB().ExecContext(ctx, "UPDATE person_contact_points SET superseded_at = CURRENT_TIMESTAMP") + require.NoError(err) + current, err := st.ListPersonContactPointsContext(ctx, personID, true) + require.NoError(err) + assert.Empty(current) + all, err := st.ListPersonContactPointsContext(ctx, personID, false) + require.NoError(err) + require.Len(all, 1) + assert.Nil(all[0].Envelope.ActiveUntil) + assert.NotNil(all[0].Envelope.SupersededAt) +} diff --git a/internal/store/person_dates.go b/internal/store/person_dates.go new file mode 100644 index 000000000..b0076626d --- /dev/null +++ b/internal/store/person_dates.go @@ -0,0 +1,211 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +type PersonDateKind string + +const ( + PersonDateBirthday PersonDateKind = "birthday" + PersonDateAnniversary PersonDateKind = "anniversary" + PersonDateDeath PersonDateKind = "death" + PersonDateCustom PersonDateKind = "custom" +) + +func (k PersonDateKind) Valid() bool { + switch k { + case PersonDateBirthday, PersonDateAnniversary, PersonDateDeath, PersonDateCustom: + return true + default: + return false + } +} + +type PersonDate struct { + Envelope ValueEnvelope `json:"envelope"` + PersonID int64 `json:"person_id"` + DateKind PersonDateKind `json:"date_kind"` + Label *string `json:"label,omitempty"` + Date PartialDate `json:"date"` + DateText *string `json:"date_text,omitempty"` + CalendarScale *string `json:"calendar_scale,omitempty"` + OriginalValue string `json:"original_value"` +} + +type PersonDateInput struct { + DateKind PersonDateKind `json:"date_kind"` + Label *string `json:"label,omitempty"` + Date PartialDate `json:"date"` + DateText *string `json:"date_text,omitempty"` + CalendarScale *string `json:"calendar_scale,omitempty"` + OriginalValue string `json:"original_value"` + Envelope ValueEnvelopeInput `json:"envelope"` +} + +var ( + ErrInvalidPersonDateKind = errors.New("invalid person date kind") + ErrPersonDateValueMissing = errors.New("person date requires a partial date or date text") +) + +func (s *Store) AddPersonDateContext( + ctx context.Context, personID int64, input PersonDateInput, +) (*PersonDate, error) { + var result *PersonDate + err := s.withTxContext(ctx, func(tx *loggedTx) error { + if err := s.lockIdentityMutationTxContext(ctx, tx); err != nil { + return err + } + if err := ensureProfilePersonTx(ctx, tx, personID); err != nil { + return err + } + var err error + result, err = s.addPersonDateTx(ctx, tx, personID, input) + if err != nil { + return err + } + if err := s.bumpPersonRevisionsTx(ctx, tx, personID); err != nil { + return err + } + return nil + }) + return result, err +} + +func (s *Store) ListPersonDatesContext( + ctx context.Context, personID int64, currentOnly bool, +) ([]PersonDate, error) { + var dates []PersonDate + err := s.withTxContext(ctx, func(tx *loggedTx) error { + var err error + dates, err = s.listPersonDatesTx(ctx, tx, personID, currentOnly) + return err + }) + return dates, err +} + +func (s *Store) SupersedePersonDateContext( + ctx context.Context, personID, dateID int64, activeUntil *time.Time, +) error { + return s.withTxContext(ctx, func(tx *loggedTx) error { + if err := s.lockIdentityMutationTxContext(ctx, tx); err != nil { + return err + } + if err := s.supersedePersonDateTx(ctx, tx, personID, dateID, activeUntil); err != nil { + return err + } + return s.bumpPersonRevisionsTx(ctx, tx, personID) + }) +} + +func (s *Store) addPersonDateTx( + ctx context.Context, tx *loggedTx, personID int64, input PersonDateInput, +) (*PersonDate, error) { + if !input.DateKind.Valid() { + return nil, ErrInvalidPersonDateKind + } + if input.Date.IsZero() && (input.DateText == nil || strings.TrimSpace(*input.DateText) == "") { + return nil, ErrPersonDateValueMissing + } + if !input.Date.IsZero() { + if err := input.Date.Validate(); err != nil { + return nil, err + } + } + original := strings.TrimSpace(input.OriginalValue) + if original == "" { + if input.Date.IsZero() { + original = strings.TrimSpace(*input.DateText) + } else { + original = input.Date.String() + } + } + env, err := resolveProfileEnvelopeTx( + ctx, tx, "person_dates", "date_kind", personID, input.DateKind, input.Envelope, + ) + if err != nil { + return nil, err + } + args := []any{personID, input.DateKind, stringValue(input.Label)} + args = append(args, PartialDateArgs(input.Date)...) + args = append(args, stringValue(input.DateText), stringValue(input.CalendarScale), original) + args = append(args, profileEnvelopeArgs(env)...) + var id int64 + if err := tx.QueryRowContext(ctx, `INSERT INTO person_dates ( + person_id, date_kind, label, date_year, date_month, date_day, + date_text, calendar_scale, original_value, `+profileEnvelopeWriteColumns+`, + created_at, updated_at + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + `+s.dialect.Now()+`, `+s.dialect.Now()+` + ) RETURNING id`, args...).Scan(&id); err != nil { + return nil, fmt.Errorf("add person date: %w", err) + } + return getPersonDateTx(ctx, tx, personID, id) +} + +func (s *Store) listPersonDatesTx( + ctx context.Context, tx *loggedTx, personID int64, currentOnly bool, +) ([]PersonDate, error) { + query := personDateSelect + ` WHERE person_id = ?` + if currentOnly { + query += ` AND active_until IS NULL AND superseded_at IS NULL` + } + query += ` ORDER BY date_kind, + CASE WHEN pref IS NULL THEN 1 ELSE 0 END, pref, ordinal, id` + return queryProfileRowsTx(ctx, tx, query, scanPersonDate, personID) +} + +func (s *Store) supersedePersonDateTx( + ctx context.Context, tx *loggedTx, personID, dateID int64, activeUntil *time.Time, +) error { + return s.supersedeProfileValueTx( + ctx, tx, "person_dates", personID, dateID, activeUntil, + ) +} + +const personDateSelect = `SELECT + id, person_id, date_kind, label, date_year, date_month, date_day, + date_text, calendar_scale, original_value, ` + profileEnvelopeReadColumns + ` + FROM person_dates` + +func getPersonDateTx( + ctx context.Context, tx *loggedTx, personID, id int64, +) (*PersonDate, error) { + date, err := scanPersonDate(tx.QueryRowContext(ctx, + personDateSelect+` WHERE person_id = ? AND id = ?`, personID, id, + )) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrProfileValueNotFound + } + return date, err +} + +func scanPersonDate(row scanner) (*PersonDate, error) { + var date PersonDate + var label, dateText, calendarScale sql.NullString + var year, month, day sql.NullInt64 + var env profileEnvelopeScanValues + dest := []any{ + &date.Envelope.ID, &date.PersonID, &date.DateKind, &label, + &year, &month, &day, &dateText, &calendarScale, &date.OriginalValue, + } + dest = append(dest, env.destinations()...) + if err := row.Scan(dest...); err != nil { + return nil, err + } + date.Label = nullStringPtr(label) + date.Date = ScanPartialDate(year, month, day) + date.DateText = nullStringPtr(dateText) + date.CalendarScale = nullStringPtr(calendarScale) + if err := env.apply(&date.Envelope); err != nil { + return nil, err + } + return &date, nil +} diff --git a/internal/store/person_dates_test.go b/internal/store/person_dates_test.go new file mode 100644 index 000000000..c689e81de --- /dev/null +++ b/internal/store/person_dates_test.go @@ -0,0 +1,133 @@ +package store_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil/storetest" +) + +func TestPersonDatesRoundTripPartialPrecision(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + personID := newTestPerson(t, st) + tests := []struct { + kind store.PersonDateKind + date store.PartialDate + property string + raw string + }{ + {store.PersonDateBirthday, partialDate(0, 4, 12), "BDAY", "--0412"}, + {store.PersonDateBirthday, partialDate(0, 4, 0), "BDAY", "--04"}, + {store.PersonDateAnniversary, partialDate(2014, 6, 0), "ANNIVERSARY", "2014-06"}, + {store.PersonDateDeath, partialDate(2024, 0, 0), "DEATHDATE", "2024"}, + {store.PersonDateCustom, partialDate(2019, 9, 30), "X-DATE", "20190930"}, + } + for _, test := range tests { + stored, err := st.AddPersonDateContext(ctx, personID, store.PersonDateInput{ + DateKind: test.kind, Date: test.date, OriginalValue: test.raw, + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceVCardImport, + VCard: store.VCardIdentity{Property: test.property}}, + }) + require.NoError(err) + assert.Equal(test.date, stored.Date) + } + dates, err := st.ListPersonDatesContext(ctx, personID, true) + require.NoError(err) + assert.Len(dates, 5) +} + +func TestPersonDateDayOnlyRoundTrips(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + personID := newTestPerson(t, st) + dayOnly := partialDate(0, 0, 12) + + stored, err := st.AddPersonDateContext(ctx, personID, store.PersonDateInput{ + DateKind: store.PersonDateBirthday, + Date: dayOnly, + OriginalValue: "---12", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceVCardImport}, + }) + require.NoError(err) + assert.Equal(dayOnly, stored.Date) + assert.Equal("---12", stored.Date.String()) + + dates, err := st.ListPersonDatesContext(ctx, personID, true) + require.NoError(err) + require.Len(dates, 1) + assert.Equal(dayOnly, dates[0].Date) +} + +func TestPersonDateAcceptsTextValueAndCalendarScale(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + personID := newTestPerson(t, st) + stored, err := st.AddPersonDateContext(context.Background(), personID, store.PersonDateInput{ + DateKind: store.PersonDateBirthday, DateText: new("circa 1800"), + CalendarScale: new("gregorian"), OriginalValue: "circa 1800", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceVCardImport}, + }) + require.NoError(err) + assert.True(stored.Date.IsZero()) + assert.Equal("circa 1800", *stored.DateText) +} + +func TestPersonDateValidation(t *testing.T) { + require := require.New(t) + st := storetest.New(t).Store + ctx := context.Background() + personID := newTestPerson(t, st) + _, err := st.AddPersonDateContext(ctx, personID, store.PersonDateInput{ + DateKind: "graduation", Date: partialDate(2001, 5, 1), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.ErrorIs(err, store.ErrInvalidPersonDateKind) + _, err = st.AddPersonDateContext(ctx, personID, store.PersonDateInput{ + DateKind: store.PersonDateBirthday, + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.ErrorIs(err, store.ErrPersonDateValueMissing) + _, err = st.AddPersonDateContext(ctx, personID, store.PersonDateInput{ + DateKind: store.PersonDateBirthday, Date: partialDate(1985, 2, 29), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.ErrorIs(err, store.ErrInvalidPartialDate) +} + +func TestPersonDateComponentChecksAreEnforcedBySQL(t *testing.T) { + require := require.New(t) + st := storetest.New(t).Store + ctx := context.Background() + personID := newTestPerson(t, st) + insert := "INSERT INTO person_dates " + + "(person_id, date_kind, date_year, date_month, date_day, original_value, source, confidence) VALUES " + for _, values := range []string{ + "(?, 'birthday', 0, 4, 12, 'x', 'user', NULL)", + "(?, 'birthday', 1985, 13, 1, 'x', 'user', NULL)", + "(?, 'birthday', 1985, NULL, 12, 'x', 'user', NULL)", + "(?, 'birthday', 1985, 4, 12, 'x', 'user', 0.5)", + "(?, 'birthday', 1985, 4, 12, 'x', 'extraction', 1.5)", + } { + _, err := st.DB().ExecContext(ctx, st.Rebind(insert+values), personID) + require.Error(err, values) + } + for _, values := range []string{ + "(?, 'birthday', NULL, 4, 12, '--0412', 'user', NULL)", + "(?, 'birthday', NULL, 4, NULL, '--04', 'user', NULL)", + "(?, 'birthday', NULL, NULL, 12, '---12', 'user', NULL)", + "(?, 'death', 2024, NULL, NULL, '2024', 'user', NULL)", + "(?, 'birthday', 1985, 4, 12, 'x', 'system', 0.5)", + } { + _, err := st.DB().ExecContext(ctx, st.Rebind(insert+values), personID) + require.NoError(err, values) + } +} diff --git a/internal/store/person_media.go b/internal/store/person_media.go new file mode 100644 index 000000000..972007d59 --- /dev/null +++ b/internal/store/person_media.go @@ -0,0 +1,242 @@ +package store + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" +) + +type PersonMediaKind string + +const ( + PersonMediaPhoto PersonMediaKind = "photo" + PersonMediaLogo PersonMediaKind = "logo" + PersonMediaSound PersonMediaKind = "sound" + PersonMediaKey PersonMediaKind = "key" +) + +func (k PersonMediaKind) Valid() bool { + switch k { + case PersonMediaPhoto, PersonMediaLogo, PersonMediaSound, PersonMediaKey: + return true + default: + return false + } +} + +const MaxPersonMediaBytes = 8 << 20 + +type PersonMedia struct { + Envelope ValueEnvelope `json:"envelope"` + PersonID int64 `json:"person_id"` + MediaKind PersonMediaKind `json:"media_kind"` + MediaType *string `json:"media_type,omitempty"` + URI *string `json:"uri,omitempty"` + ByteSize *int64 `json:"byte_size,omitempty"` + ContentHash *string `json:"content_hash,omitempty"` + HasData bool `json:"has_data"` + OriginalValue string `json:"original_value"` +} + +type PersonMediaInput struct { + MediaKind PersonMediaKind `json:"media_kind"` + MediaType *string `json:"media_type,omitempty"` + URI *string `json:"uri,omitempty"` + Data []byte `json:"data,omitempty"` + OriginalValue string `json:"original_value"` + Envelope ValueEnvelopeInput `json:"envelope"` +} + +var ( + ErrInvalidPersonMediaKind = errors.New("invalid person media kind") + ErrPersonMediaEmpty = errors.New("person media requires inline data or a URI") + ErrPersonMediaTooLarge = errors.New("person media exceeds the maximum inline size") + ErrPersonMediaNoData = errors.New("person media row has no inline data") +) + +func (s *Store) AddPersonMediaContext( + ctx context.Context, personID int64, input PersonMediaInput, +) (*PersonMedia, error) { + var result *PersonMedia + err := s.withTxContext(ctx, func(tx *loggedTx) error { + if err := s.lockIdentityMutationTxContext(ctx, tx); err != nil { + return err + } + if err := ensureProfilePersonTx(ctx, tx, personID); err != nil { + return err + } + var err error + result, err = s.addPersonMediaTx(ctx, tx, personID, input) + if err != nil { + return err + } + if err := s.bumpPersonRevisionsTx(ctx, tx, personID); err != nil { + return err + } + return nil + }) + return result, err +} + +func (s *Store) ListPersonMediaContext( + ctx context.Context, personID int64, currentOnly bool, +) ([]PersonMedia, error) { + var media []PersonMedia + err := s.withTxContext(ctx, func(tx *loggedTx) error { + var err error + media, err = s.listPersonMediaTx(ctx, tx, personID, currentOnly) + return err + }) + return media, err +} + +func (s *Store) ReadPersonMediaDataContext( + ctx context.Context, personID, mediaID int64, +) ([]byte, string, error) { + var data []byte + var mediaType sql.NullString + err := s.db.QueryRowContext(ctx, + `SELECT data, media_type FROM person_media WHERE person_id = ? AND id = ?`, + personID, mediaID, + ).Scan(&data, &mediaType) + if errors.Is(err, sql.ErrNoRows) { + return nil, "", ErrProfileValueNotFound + } + if err != nil { + return nil, "", fmt.Errorf("read person media data: %w", err) + } + if len(data) == 0 { + return nil, "", ErrPersonMediaNoData + } + return data, mediaType.String, nil +} + +func (s *Store) SupersedePersonMediaContext( + ctx context.Context, personID, mediaID int64, activeUntil *time.Time, +) error { + return s.withTxContext(ctx, func(tx *loggedTx) error { + if err := s.lockIdentityMutationTxContext(ctx, tx); err != nil { + return err + } + if err := s.supersedePersonMediaTx(ctx, tx, personID, mediaID, activeUntil); err != nil { + return err + } + return s.bumpPersonRevisionsTx(ctx, tx, personID) + }) +} + +func (s *Store) addPersonMediaTx( + ctx context.Context, tx *loggedTx, personID int64, input PersonMediaInput, +) (*PersonMedia, error) { + if !input.MediaKind.Valid() { + return nil, ErrInvalidPersonMediaKind + } + hasURI := input.URI != nil && strings.TrimSpace(*input.URI) != "" + if len(input.Data) == 0 && !hasURI { + return nil, ErrPersonMediaEmpty + } + if len(input.Data) > MaxPersonMediaBytes { + return nil, ErrPersonMediaTooLarge + } + original := input.OriginalValue + if strings.TrimSpace(original) == "" && hasURI { + original = strings.TrimSpace(*input.URI) + } + var data, byteSize, contentHash any + if len(input.Data) > 0 { + digest := sha256.Sum256(input.Data) + data, byteSize = input.Data, int64(len(input.Data)) + contentHash = hex.EncodeToString(digest[:]) + } + env, err := resolveProfileEnvelopeTx( + ctx, tx, "person_media", "media_kind", personID, input.MediaKind, input.Envelope, + ) + if err != nil { + return nil, err + } + args := []any{ + personID, input.MediaKind, stringValue(input.MediaType), + stringValue(input.URI), data, byteSize, contentHash, original, + } + args = append(args, profileEnvelopeArgs(env)...) + var id int64 + if err := tx.QueryRowContext(ctx, `INSERT INTO person_media ( + person_id, media_kind, media_type, uri, data, byte_size, + content_hash, original_value, `+profileEnvelopeWriteColumns+`, + created_at, updated_at + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + `+s.dialect.Now()+`, `+s.dialect.Now()+` + ) RETURNING id`, args...).Scan(&id); err != nil { + return nil, fmt.Errorf("add person media: %w", err) + } + return getPersonMediaTx(ctx, tx, personID, id) +} + +func (s *Store) listPersonMediaTx( + ctx context.Context, tx *loggedTx, personID int64, currentOnly bool, +) ([]PersonMedia, error) { + query := personMediaSelect + ` WHERE person_id = ?` + if currentOnly { + query += ` AND active_until IS NULL AND superseded_at IS NULL` + } + query += ` ORDER BY media_kind, + CASE WHEN pref IS NULL THEN 1 ELSE 0 END, pref, ordinal, id` + return queryProfileRowsTx(ctx, tx, query, scanPersonMedia, personID) +} + +func (s *Store) supersedePersonMediaTx( + ctx context.Context, tx *loggedTx, personID, mediaID int64, activeUntil *time.Time, +) error { + return s.supersedeProfileValueTx( + ctx, tx, "person_media", personID, mediaID, activeUntil, + ) +} + +const personMediaSelect = `SELECT + id, person_id, media_kind, media_type, uri, byte_size, content_hash, + (data IS NOT NULL) AS has_data, original_value, ` + profileEnvelopeReadColumns + ` + FROM person_media` + +func getPersonMediaTx( + ctx context.Context, tx *loggedTx, personID, id int64, +) (*PersonMedia, error) { + media, err := scanPersonMedia(tx.QueryRowContext(ctx, + personMediaSelect+` WHERE person_id = ? AND id = ?`, personID, id, + )) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrProfileValueNotFound + } + return media, err +} + +func scanPersonMedia(row scanner) (*PersonMedia, error) { + var media PersonMedia + var mediaType, uri, contentHash sql.NullString + var byteSize sql.NullInt64 + var env profileEnvelopeScanValues + dest := []any{ + &media.Envelope.ID, &media.PersonID, &media.MediaKind, &mediaType, + &uri, &byteSize, &contentHash, &media.HasData, &media.OriginalValue, + } + dest = append(dest, env.destinations()...) + if err := row.Scan(dest...); err != nil { + return nil, err + } + media.MediaType = nullStringPtr(mediaType) + media.URI = nullStringPtr(uri) + if byteSize.Valid { + media.ByteSize = &byteSize.Int64 + } + media.ContentHash = nullStringPtr(contentHash) + if err := env.apply(&media.Envelope); err != nil { + return nil, err + } + return &media, nil +} diff --git a/internal/store/person_media_test.go b/internal/store/person_media_test.go new file mode 100644 index 000000000..eafb92842 --- /dev/null +++ b/internal/store/person_media_test.go @@ -0,0 +1,105 @@ +package store_test + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil/storetest" +) + +func TestPersonMediaStoresInlineBytesWithHashAndSize(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + personID := newTestPerson(t, st) + payload := bytes.Repeat([]byte{0x89, 0x50, 0x4e, 0x47}, 64) + digest := sha256.Sum256(payload) + stored, err := st.AddPersonMediaContext(ctx, personID, store.PersonMediaInput{ + MediaKind: store.PersonMediaPhoto, MediaType: new("image/png"), + Data: payload, OriginalValue: "data:image/png;base64,", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceCardDAVImport, + SourceRef: new("resource-1"), VCard: store.VCardIdentity{ + Property: "PHOTO", PropID: new("p1"), + }}, + }) + require.NoError(err) + assert.True(stored.HasData) + assert.Equal(int64(len(payload)), *stored.ByteSize) + assert.Equal(hex.EncodeToString(digest[:]), *stored.ContentHash) + data, mediaType, err := st.ReadPersonMediaDataContext(ctx, personID, stored.Envelope.ID) + require.NoError(err) + assert.Equal(payload, data) + assert.Equal("image/png", mediaType) +} + +func TestPersonMediaStoresURIReferenceWithoutBytes(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + personID := newTestPerson(t, st) + stored, err := st.AddPersonMediaContext(ctx, personID, store.PersonMediaInput{ + MediaKind: store.PersonMediaPhoto, URI: new("https://example.com/alice.png"), + MediaType: new("image/png"), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceVCardImport}, + }) + require.NoError(err) + assert.False(stored.HasData) + assert.Nil(stored.ByteSize) + _, _, err = st.ReadPersonMediaDataContext(ctx, personID, stored.Envelope.ID) + require.ErrorIs(err, store.ErrPersonMediaNoData) +} + +func TestPersonMediaHoldsAllFourVCardMediaKinds(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + personID := newTestPerson(t, st) + for _, kind := range []store.PersonMediaKind{ + store.PersonMediaPhoto, store.PersonMediaLogo, store.PersonMediaSound, store.PersonMediaKey, + } { + _, err := st.AddPersonMediaContext(ctx, personID, store.PersonMediaInput{ + MediaKind: kind, Data: []byte("synthetic-" + string(kind)), + OriginalValue: "data:application/octet-stream;base64,", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceVCardImport}, + }) + require.NoError(err) + } + media, err := st.ListPersonMediaContext(ctx, personID, true) + require.NoError(err) + require.Len(media, 4) + for _, row := range media { + assert.True(row.HasData) + assert.NotNil(row.ContentHash) + } +} + +func TestPersonMediaValidation(t *testing.T) { + require := require.New(t) + st := storetest.New(t).Store + personID := newTestPerson(t, st) + ctx := context.Background() + _, err := st.AddPersonMediaContext(ctx, personID, store.PersonMediaInput{ + MediaKind: "avatar", Data: []byte("synthetic"), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.ErrorIs(err, store.ErrInvalidPersonMediaKind) + _, err = st.AddPersonMediaContext(ctx, personID, store.PersonMediaInput{ + MediaKind: store.PersonMediaPhoto, + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.ErrorIs(err, store.ErrPersonMediaEmpty) + _, err = st.AddPersonMediaContext(ctx, personID, store.PersonMediaInput{ + MediaKind: store.PersonMediaPhoto, Data: make([]byte, store.MaxPersonMediaBytes+1), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.ErrorIs(err, store.ErrPersonMediaTooLarge) +} diff --git a/internal/store/person_names.go b/internal/store/person_names.go new file mode 100644 index 000000000..6fc77a06d --- /dev/null +++ b/internal/store/person_names.go @@ -0,0 +1,258 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +type PersonNameKind string + +const ( + PersonNameFormatted PersonNameKind = "formatted" + PersonNameStructured PersonNameKind = "structured" + PersonNameNickname PersonNameKind = "nickname" + PersonNamePhonetic PersonNameKind = "phonetic" + PersonNameSort PersonNameKind = "sort" +) + +func (k PersonNameKind) Valid() bool { + switch k { + case PersonNameFormatted, PersonNameStructured, PersonNameNickname, + PersonNamePhonetic, PersonNameSort: + return true + default: + return false + } +} + +type PersonName struct { + Envelope ValueEnvelope `json:"envelope"` + PersonID int64 `json:"person_id"` + NameKind PersonNameKind `json:"name_kind"` + Formatted *string `json:"formatted,omitempty"` + FamilyName *string `json:"family_name,omitempty"` + GivenName *string `json:"given_name,omitempty"` + AdditionalNames *string `json:"additional_names,omitempty"` + HonorificPrefixes *string `json:"honorific_prefixes,omitempty"` + HonorificSuffixes *string `json:"honorific_suffixes,omitempty"` + SecondarySurname *string `json:"secondary_surname,omitempty"` + Generation *string `json:"generation,omitempty"` + Language *string `json:"language,omitempty"` + Script *string `json:"script,omitempty"` + PhoneticSystem *string `json:"phonetic_system,omitempty"` + PhoneticScript *string `json:"phonetic_script,omitempty"` + SortAs *string `json:"sort_as,omitempty"` + IsDerived bool `json:"is_derived"` + OriginalValue string `json:"original_value"` +} + +type PersonNameInput struct { + NameKind PersonNameKind `json:"name_kind"` + Formatted *string `json:"formatted,omitempty"` + FamilyName *string `json:"family_name,omitempty"` + GivenName *string `json:"given_name,omitempty"` + AdditionalNames *string `json:"additional_names,omitempty"` + HonorificPrefixes *string `json:"honorific_prefixes,omitempty"` + HonorificSuffixes *string `json:"honorific_suffixes,omitempty"` + SecondarySurname *string `json:"secondary_surname,omitempty"` + Generation *string `json:"generation,omitempty"` + Language *string `json:"language,omitempty"` + Script *string `json:"script,omitempty"` + PhoneticSystem *string `json:"phonetic_system,omitempty"` + PhoneticScript *string `json:"phonetic_script,omitempty"` + SortAs *string `json:"sort_as,omitempty"` + IsDerived bool `json:"is_derived,omitempty"` + OriginalValue string `json:"original_value"` + Envelope ValueEnvelopeInput `json:"envelope"` +} + +var ( + ErrInvalidPersonNameKind = errors.New("invalid person name kind") + ErrPersonNameValueMissing = errors.New("person name requires at least one non-empty component") +) + +func (s *Store) AddPersonNameContext(ctx context.Context, personID int64, input PersonNameInput) (*PersonName, error) { + var result *PersonName + err := s.withTxContext(ctx, func(tx *loggedTx) error { + if err := s.lockIdentityMutationTxContext(ctx, tx); err != nil { + return err + } + if err := ensureProfilePersonTx(ctx, tx, personID); err != nil { + return err + } + var err error + result, err = s.addPersonNameTx(ctx, tx, personID, input) + if err != nil { + return err + } + if err := s.bumpPersonRevisionsTx(ctx, tx, personID); err != nil { + return err + } + return nil + }) + return result, err +} + +func (s *Store) ListPersonNamesContext(ctx context.Context, personID int64, currentOnly bool) ([]PersonName, error) { + var names []PersonName + err := s.withTxContext(ctx, func(tx *loggedTx) error { + var err error + names, err = s.listPersonNamesTx(ctx, tx, personID, currentOnly) + return err + }) + return names, err +} + +func (s *Store) SupersedePersonNameContext(ctx context.Context, personID, nameID int64, activeUntil *time.Time) error { + return s.withTxContext(ctx, func(tx *loggedTx) error { + if err := s.lockIdentityMutationTxContext(ctx, tx); err != nil { + return err + } + if err := s.supersedePersonNameTx(ctx, tx, personID, nameID, activeUntil); err != nil { + return err + } + return s.bumpPersonRevisionsTx(ctx, tx, personID) + }) +} + +func (s *Store) addPersonNameTx( + ctx context.Context, tx *loggedTx, personID int64, input PersonNameInput, +) (*PersonName, error) { + if !input.NameKind.Valid() { + return nil, ErrInvalidPersonNameKind + } + original := strings.TrimSpace(input.OriginalValue) + if original == "" { + original = firstNonBlankNameComponent(input) + } + if original == "" { + return nil, ErrPersonNameValueMissing + } + env, err := resolveProfileEnvelopeTx( + ctx, tx, "person_names", "name_kind", personID, input.NameKind, input.Envelope, + ) + if err != nil { + return nil, err + } + args := []any{ + personID, input.NameKind, stringValue(input.Formatted), + stringValue(input.FamilyName), stringValue(input.GivenName), + stringValue(input.AdditionalNames), stringValue(input.HonorificPrefixes), + stringValue(input.HonorificSuffixes), stringValue(input.SecondarySurname), + stringValue(input.Generation), stringValue(input.Language), + stringValue(input.Script), stringValue(input.PhoneticSystem), + stringValue(input.PhoneticScript), stringValue(input.SortAs), + input.IsDerived, original, + } + args = append(args, profileEnvelopeArgs(env)...) + var id int64 + if err := tx.QueryRowContext(ctx, `INSERT INTO person_names ( + person_id, name_kind, formatted, family_name, given_name, + additional_names, honorific_prefixes, honorific_suffixes, + secondary_surname, generation, language, script, phonetic_system, + phonetic_script, sort_as, is_derived, original_value, `+ + profileEnvelopeWriteColumns+`, created_at, updated_at + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + `+s.dialect.Now()+`, `+s.dialect.Now()+` + ) RETURNING id`, args...).Scan(&id); err != nil { + return nil, fmt.Errorf("add person name: %w", err) + } + return getPersonNameTx(ctx, tx, personID, id) +} + +func (s *Store) listPersonNamesTx( + ctx context.Context, tx *loggedTx, personID int64, currentOnly bool, +) ([]PersonName, error) { + query := personNameSelect + ` WHERE person_id = ?` + if currentOnly { + query += ` AND active_until IS NULL AND superseded_at IS NULL` + } + query += ` ORDER BY name_kind, + CASE WHEN pref IS NULL THEN 1 ELSE 0 END, pref, ordinal, id` + return queryProfileRowsTx(ctx, tx, query, scanPersonName, personID) +} + +func (s *Store) supersedePersonNameTx( + ctx context.Context, tx *loggedTx, personID, nameID int64, activeUntil *time.Time, +) error { + return s.supersedeProfileValueTx( + ctx, tx, "person_names", personID, nameID, activeUntil, + ) +} + +func firstNonBlankNameComponent(input PersonNameInput) string { + for _, value := range []*string{ + input.Formatted, input.FamilyName, input.GivenName, input.AdditionalNames, + input.HonorificPrefixes, input.HonorificSuffixes, input.SecondarySurname, + input.Generation, input.SortAs, + } { + if value != nil && strings.TrimSpace(*value) != "" { + return strings.TrimSpace(*value) + } + } + return "" +} + +const personNameSelect = `SELECT + id, person_id, name_kind, formatted, family_name, given_name, + additional_names, honorific_prefixes, honorific_suffixes, + secondary_surname, generation, language, script, phonetic_system, + phonetic_script, sort_as, is_derived, original_value, + pref, ordinal, type_label, type_tokens, vcard_property, vcard_group, + vcard_prop_id, vcard_pid, vcard_altid, source, source_ref, confidence, + active_from, active_until, created_at, updated_at, superseded_at + FROM person_names` + +func getPersonNameTx(ctx context.Context, tx *loggedTx, personID, id int64) (*PersonName, error) { + name, err := scanPersonName(tx.QueryRowContext(ctx, + personNameSelect+` WHERE person_id = ? AND id = ?`, personID, id, + )) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrProfileValueNotFound + } + if err != nil { + return nil, fmt.Errorf("get person name: %w", err) + } + return name, nil +} + +func scanPersonName(row scanner) (*PersonName, error) { + var name PersonName + var formatted, family, given, additional, prefixes, suffixes sql.NullString + var secondary, generation, language, script, phoneticSystem sql.NullString + var phoneticScript, sortAs sql.NullString + var env profileEnvelopeScanValues + dest := []any{ + &name.Envelope.ID, &name.PersonID, &name.NameKind, + &formatted, &family, &given, &additional, &prefixes, &suffixes, + &secondary, &generation, &language, &script, &phoneticSystem, + &phoneticScript, &sortAs, &name.IsDerived, &name.OriginalValue, + } + dest = append(dest, env.destinations()...) + if err := row.Scan(dest...); err != nil { + return nil, err + } + name.Formatted = nullStringPtr(formatted) + name.FamilyName = nullStringPtr(family) + name.GivenName = nullStringPtr(given) + name.AdditionalNames = nullStringPtr(additional) + name.HonorificPrefixes = nullStringPtr(prefixes) + name.HonorificSuffixes = nullStringPtr(suffixes) + name.SecondarySurname = nullStringPtr(secondary) + name.Generation = nullStringPtr(generation) + name.Language = nullStringPtr(language) + name.Script = nullStringPtr(script) + name.PhoneticSystem = nullStringPtr(phoneticSystem) + name.PhoneticScript = nullStringPtr(phoneticScript) + name.SortAs = nullStringPtr(sortAs) + if err := env.apply(&name.Envelope); err != nil { + return nil, err + } + return &name, nil +} diff --git a/internal/store/person_names_test.go b/internal/store/person_names_test.go new file mode 100644 index 000000000..d5fd0bf1d --- /dev/null +++ b/internal/store/person_names_test.go @@ -0,0 +1,141 @@ +package store_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil/storetest" +) + +func TestPersonNamesRetainStructuredComponentsAndRFC9554Fields(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + personID := newTestPerson(t, st) + + structured, err := st.AddPersonNameContext(ctx, personID, store.PersonNameInput{ + NameKind: store.PersonNameStructured, FamilyName: new("Example"), + GivenName: new("Alice"), AdditionalNames: new("Q"), + HonorificPrefixes: new("Dr."), HonorificSuffixes: new("PhD"), + SecondarySurname: new("Sample"), Generation: new("Jr."), + Script: new("Latn"), SortAs: new("Example,Alice"), + OriginalValue: "Example;Alice;Q;Dr.;PhD;Sample;Jr.", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceVCardImport, + VCard: store.VCardIdentity{Property: "N", PropID: new("n1")}}, + }) + require.NoError(err) + assert.Equal(store.PersonNameStructured, structured.NameKind) + assert.Equal("Sample", *structured.SecondarySurname) + assert.Equal("Jr.", *structured.Generation) + assert.True(structured.Envelope.IsCurrent()) + + _, err = st.AddPersonNameContext(ctx, personID, store.PersonNameInput{ + NameKind: store.PersonNamePhonetic, GivenName: new("synthetic"), + Language: new("en"), PhoneticSystem: new("ipa"), + OriginalValue: ";synthetic;;;", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceVCardImport, + VCard: store.VCardIdentity{Property: "N", AltID: new("1")}}, + }) + require.NoError(err) + names, err := st.ListPersonNamesContext(ctx, personID, true) + require.NoError(err) + assert.Len(names, 2) +} + +func TestPersonNamesKeepMultipleFormattedFormsPerLanguage(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + personID := newTestPerson(t, st) + + for _, form := range []struct { + formatted, language string + pref int + }{{"Alice Example", "en", 1}, {"Synthetic Alternate", "ja", 2}} { + _, err := st.AddPersonNameContext(ctx, personID, store.PersonNameInput{ + NameKind: store.PersonNameFormatted, Formatted: new(form.formatted), + Language: new(form.language), OriginalValue: form.formatted, + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceCardDAVImport, + Pref: new(form.pref), VCard: store.VCardIdentity{Property: "FN", AltID: new("1")}}, + }) + require.NoError(err) + } + names, err := st.ListPersonNamesContext(ctx, personID, true) + require.NoError(err) + require.Len(names, 2) + assert.Equal(1, *names[0].Envelope.Pref) + assert.Equal("Alice Example", *names[0].Formatted) +} + +func TestSupersedePersonNameClosesBothTimeAxes(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + personID := newTestPerson(t, st) + name, err := st.AddPersonNameContext(ctx, personID, store.PersonNameInput{ + NameKind: store.PersonNameFormatted, Formatted: new("Alice Example"), + OriginalValue: "Alice Example", Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(err) + require.NoError(st.SupersedePersonNameContext(ctx, personID, name.Envelope.ID, nil)) + current, err := st.ListPersonNamesContext(ctx, personID, true) + require.NoError(err) + assert.Empty(current) + all, err := st.ListPersonNamesContext(ctx, personID, false) + require.NoError(err) + require.Len(all, 1) + assert.NotNil(all[0].Envelope.SupersededAt) + assert.NotNil(all[0].Envelope.ActiveUntil) + assert.False(all[0].Envelope.IsCurrent()) +} + +func TestAddPersonNameRejectsInvalidInput(t *testing.T) { + require := require.New(t) + st := storetest.New(t).Store + ctx := context.Background() + personID := newTestPerson(t, st) + _, err := st.AddPersonNameContext(ctx, personID, store.PersonNameInput{ + NameKind: "middle", Formatted: new("Alice Example"), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.ErrorIs(err, store.ErrInvalidPersonNameKind) + _, err = st.AddPersonNameContext(ctx, personID, store.PersonNameInput{ + NameKind: store.PersonNameFormatted, Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.ErrorIs(err, store.ErrPersonNameValueMissing) + _, err = st.AddPersonNameContext(ctx, 999999, store.PersonNameInput{ + NameKind: store.PersonNameFormatted, Formatted: new("Alice Example"), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.ErrorIs(err, store.ErrPersonNotFound) +} + +func partialDate(year, month, day int) store.PartialDate { + date := store.PartialDate{} + if year != 0 { + date.Year = new(year) + } + if month != 0 { + date.Month = new(month) + } + if day != 0 { + date.Day = new(day) + } + return date +} + +func newTestPerson(t *testing.T, st *store.Store) int64 { + t.Helper() + require := require.New(t) + participantID, err := st.EnsureParticipantByIdentifier("email", "alice@example.com", "Alice Example") + require.NoError(err) + person, _, err := st.CreatePersonFromParticipantContext(context.Background(), participantID) + require.NoError(err) + return person.ID +} diff --git a/internal/store/person_profile.go b/internal/store/person_profile.go new file mode 100644 index 000000000..89e08e7f4 --- /dev/null +++ b/internal/store/person_profile.go @@ -0,0 +1,282 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +type PersonProfile struct { + Person Person `json:"person"` + Names []PersonName `json:"names"` + ContactPoints []PersonContactPoint `json:"contact_points"` + Addresses []PersonAddress `json:"addresses"` + Dates []PersonDate `json:"dates"` + Categories []PersonCategory `json:"categories"` + Media []PersonMedia `json:"media"` +} + +type PersonProfilePatch struct { + Names *PersonNamePatch `json:"names,omitempty"` + ContactPoints *PersonContactPointPatch `json:"contact_points,omitempty"` + Addresses *PersonAddressPatch `json:"addresses,omitempty"` + Dates *PersonDatePatch `json:"dates,omitempty"` + Categories *PersonCategoryPatch `json:"categories,omitempty"` + Media *PersonMediaPatch `json:"media,omitempty"` +} + +type PersonNamePatch struct { + Add []PersonNameInput `json:"add,omitempty"` + Supersede []int64 `json:"supersede,omitempty"` +} + +type PersonContactPointPatch struct { + Add []PersonContactPointInput `json:"add,omitempty"` + Supersede []int64 `json:"supersede,omitempty"` +} + +type PersonAddressPatch struct { + Add []PersonAddressInput `json:"add,omitempty"` + Supersede []int64 `json:"supersede,omitempty"` +} + +type PersonDatePatch struct { + Add []PersonDateInput `json:"add,omitempty"` + Supersede []int64 `json:"supersede,omitempty"` +} + +type PersonCategoryPatch struct { + Add []PersonCategoryInput `json:"add,omitempty"` + Supersede []int64 `json:"supersede,omitempty"` +} + +type PersonMediaPatch struct { + Add []PersonMediaInput `json:"add,omitempty"` + Supersede []int64 `json:"supersede,omitempty"` +} + +type PersonProfileHistory struct { + Person Person `json:"person"` + Names []PersonName `json:"names"` + ContactPoints []PersonContactPoint `json:"contact_points"` + Addresses []PersonAddress `json:"addresses"` + Dates []PersonDate `json:"dates"` + Categories []PersonCategory `json:"categories"` + Media []PersonMedia `json:"media"` + Observations []ParticipantContactObservation `json:"observations"` +} + +const MaxPersonProfilePatchOperations = 200 + +var ( + ErrPersonProfilePatchTooLarge = errors.New("person profile patch exceeds the operation limit") + ErrPersonProfilePatchEmpty = errors.New("person profile patch contains no operations") +) + +func (s *Store) GetPersonProfileContext( + ctx context.Context, personID int64, +) (*PersonProfile, error) { + var profile *PersonProfile + err := s.withReadSnapshotContext(ctx, func(tx *loggedTx) error { + var err error + profile, err = s.getPersonProfileTx(ctx, tx, personID, true) + return err + }) + return profile, err +} + +func (s *Store) ApplyPersonProfilePatchContext( + ctx context.Context, + personID, expectedRevision int64, + patch PersonProfilePatch, +) (*PersonProfile, error) { + operations := countPersonProfilePatchOperations(patch) + if operations == 0 { + return nil, ErrPersonProfilePatchEmpty + } + if operations > MaxPersonProfilePatchOperations { + return nil, ErrPersonProfilePatchTooLarge + } + var profile *PersonProfile + err := s.withTxContext(ctx, func(tx *loggedTx) error { + if err := s.lockIdentityMutationTxContext(ctx, tx); err != nil { + return err + } + var updatedID int64 + err := tx.QueryRowContext(ctx, `UPDATE persons + SET revision = revision + 1, updated_at = `+s.dialect.Now()+` + WHERE id = ? AND revision = ? RETURNING id`, + personID, expectedRevision, + ).Scan(&updatedID) + if errors.Is(err, sql.ErrNoRows) { + return s.personCASMissTx(ctx, tx, personID) + } + if err != nil { + return fmt.Errorf("update person profile revision: %w", err) + } + if err := s.applyPersonProfilePatchTx(ctx, tx, personID, patch); err != nil { + return err + } + profile, err = s.getPersonProfileTx(ctx, tx, updatedID, true) + return err + }) + return profile, err +} + +func (s *Store) GetPersonProfileHistoryContext( + ctx context.Context, personID int64, +) (*PersonProfileHistory, error) { + var history *PersonProfileHistory + err := s.withReadSnapshotContext(ctx, func(tx *loggedTx) error { + profile, err := s.getPersonProfileTx(ctx, tx, personID, false) + if err != nil { + return err + } + observations, err := s.listObservationsForPersonTx(ctx, tx, personID) + if err != nil { + return err + } + history = &PersonProfileHistory{ + Person: profile.Person, Names: profile.Names, + ContactPoints: profile.ContactPoints, Addresses: profile.Addresses, + Dates: profile.Dates, Categories: profile.Categories, + Media: profile.Media, Observations: observations, + } + return nil + }) + return history, err +} + +func countPersonProfilePatchOperations(patch PersonProfilePatch) int { + count := 0 + if patch.Names != nil { + count += len(patch.Names.Add) + len(patch.Names.Supersede) + } + if patch.ContactPoints != nil { + count += len(patch.ContactPoints.Add) + len(patch.ContactPoints.Supersede) + } + if patch.Addresses != nil { + count += len(patch.Addresses.Add) + len(patch.Addresses.Supersede) + } + if patch.Dates != nil { + count += len(patch.Dates.Add) + len(patch.Dates.Supersede) + } + if patch.Categories != nil { + count += len(patch.Categories.Add) + len(patch.Categories.Supersede) + } + if patch.Media != nil { + count += len(patch.Media.Add) + len(patch.Media.Supersede) + } + return count +} + +func (s *Store) applyPersonProfilePatchTx( + ctx context.Context, + tx *loggedTx, + personID int64, + patch PersonProfilePatch, +) error { + if patch.Names != nil { + for _, id := range patch.Names.Supersede { + if err := s.supersedePersonNameTx(ctx, tx, personID, id, nil); err != nil { + return err + } + } + for _, input := range patch.Names.Add { + if _, err := s.addPersonNameTx(ctx, tx, personID, input); err != nil { + return err + } + } + } + if patch.ContactPoints != nil { + for _, id := range patch.ContactPoints.Supersede { + if err := s.supersedePersonContactPointTx(ctx, tx, personID, id, nil); err != nil { + return err + } + } + for _, input := range patch.ContactPoints.Add { + if _, err := s.addPersonContactPointTx(ctx, tx, personID, input); err != nil { + return err + } + } + } + if patch.Addresses != nil { + for _, id := range patch.Addresses.Supersede { + if err := s.supersedePersonAddressTx(ctx, tx, personID, id, nil); err != nil { + return err + } + } + for _, input := range patch.Addresses.Add { + if _, err := s.addPersonAddressTx(ctx, tx, personID, input); err != nil { + return err + } + } + } + if patch.Dates != nil { + for _, id := range patch.Dates.Supersede { + if err := s.supersedePersonDateTx(ctx, tx, personID, id, nil); err != nil { + return err + } + } + for _, input := range patch.Dates.Add { + if _, err := s.addPersonDateTx(ctx, tx, personID, input); err != nil { + return err + } + } + } + if patch.Categories != nil { + for _, id := range patch.Categories.Supersede { + if err := s.supersedePersonCategoryTx(ctx, tx, personID, id, nil); err != nil { + return err + } + } + for _, input := range patch.Categories.Add { + if _, err := s.addPersonCategoryTx(ctx, tx, personID, input); err != nil { + return err + } + } + } + if patch.Media != nil { + for _, id := range patch.Media.Supersede { + if err := s.supersedePersonMediaTx(ctx, tx, personID, id, nil); err != nil { + return err + } + } + for _, input := range patch.Media.Add { + if _, err := s.addPersonMediaTx(ctx, tx, personID, input); err != nil { + return err + } + } + } + return nil +} + +func (s *Store) getPersonProfileTx( + ctx context.Context, tx *loggedTx, personID int64, currentOnly bool, +) (*PersonProfile, error) { + person, err := s.getPersonTx(ctx, tx, personID) + if err != nil { + return nil, err + } + profile := &PersonProfile{Person: *person} + if profile.Names, err = s.listPersonNamesTx(ctx, tx, personID, currentOnly); err != nil { + return nil, err + } + if profile.ContactPoints, err = s.listPersonContactPointsTx(ctx, tx, personID, currentOnly); err != nil { + return nil, err + } + if profile.Addresses, err = s.listPersonAddressesTx(ctx, tx, personID, currentOnly); err != nil { + return nil, err + } + if profile.Dates, err = s.listPersonDatesTx(ctx, tx, personID, currentOnly); err != nil { + return nil, err + } + if profile.Categories, err = s.listPersonCategoriesTx(ctx, tx, personID, currentOnly); err != nil { + return nil, err + } + if profile.Media, err = s.listPersonMediaTx(ctx, tx, personID, currentOnly); err != nil { + return nil, err + } + return profile, nil +} diff --git a/internal/store/person_profile_backend_test.go b/internal/store/person_profile_backend_test.go new file mode 100644 index 000000000..a04c1cc4c --- /dev/null +++ b/internal/store/person_profile_backend_test.go @@ -0,0 +1,281 @@ +package store_test + +import ( + "context" + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil/storetest" +) + +var profileEnvelopeColumnNames = []string{ + "pref", "ordinal", "type_label", "type_tokens", + "vcard_property", "vcard_group", "vcard_prop_id", "vcard_pid", "vcard_altid", + "source", "source_ref", "confidence", + "active_from", "active_until", + "created_at", "updated_at", "superseded_at", +} + +func TestProfileTableColumnsMatchOnTheConfiguredBackend(t *testing.T) { + assert := assert.New(t) + st := storetest.New(t).Store + + valueTableColumns := func(specific ...string) []string { + columns := append([]string{"id", "person_id"}, specific...) + return append(columns, profileEnvelopeColumnNames...) + } + + tests := []struct { + table string + want []string + }{ + { + table: "communication_services", + want: []string{ + "id", "slug", "display_label", "scope_policy", "default_scope_kind", + "normalization", "normalization_version", "uri_scheme", + "profile_url_template", "is_system", "is_active", + "created_at", "updated_at", + }, + }, + {table: "communication_service_aliases", want: []string{"alias", "service_id"}}, + { + table: "person_names", + want: valueTableColumns( + "name_kind", "formatted", "family_name", "given_name", + "additional_names", "honorific_prefixes", "honorific_suffixes", + "secondary_surname", "generation", "language", "script", + "phonetic_system", "phonetic_script", "sort_as", "is_derived", + "original_value", + ), + }, + { + table: "person_contact_points", + want: valueTableColumns( + "address_kind", "service_id", "scope_kind", "scope_value", + "original_value", "normalized_value", "normalization", + "normalization_version", "uri", + ), + }, + { + table: "person_addresses", + want: valueTableColumns( + "address_kind", "post_office_box", "extended_address", + "street_address", "locality", "region", "postal_code", + "country_name", "extended_components", "free_text", "label", + "geo_uri", "timezone", "country_code", "place_uri", + "original_value", + ), + }, + { + table: "person_dates", + want: valueTableColumns( + "date_kind", "label", "date_year", "date_month", "date_day", + "date_text", "calendar_scale", "original_value", + ), + }, + { + table: "person_categories", + want: valueTableColumns("original_value", "normalized_value"), + }, + { + table: "person_media", + want: valueTableColumns( + "media_kind", "media_type", "uri", "data", "byte_size", + "content_hash", "original_value", + ), + }, + { + table: "participant_contact_observations", + want: append( + []string{ + "id", "participant_id", "source_id", "address_kind", + "service_id", "scope_kind", "scope_value", "provider_user_id", + "original_value", "normalized_value", "normalization", + "normalization_version", "observed_at", + }, + profileEnvelopeColumnNames..., + ), + }, + { + table: "identity_match_candidates", + want: []string{ + "id", "left_kind", "left_id", "right_kind", "right_id", "basis", + "service_id", "scope_kind", "scope_value", "normalized_value", + "state", "confidence", "source", "source_ref", "observation_conflict_origin", + "pre_conflict_state", + "decided_by", "decided_at", "notes", "created_at", "updated_at", + }, + }, + { + table: "identity_match_evidence", + want: []string{ + "id", "candidate_id", "evidence_kind", "evidence_ref", "detail", + "source", "created_at", + }, + }, + } + for _, tc := range tests { + got := liveTableColumns(t, st, tc.table) + want := append([]string(nil), tc.want...) + sort.Strings(want) + assert.Equal(want, got, "column set for %s", tc.table) + } +} + +func TestParticipantIdentifiersGainedServiceScopeColumns(t *testing.T) { + assert := assert.New(t) + st := storetest.New(t).Store + + columns := liveTableColumns(t, st, "participant_identifiers") + for _, column := range []string{"service_id", "scope_kind", "scope_value"} { + assert.Contains(columns, column) + } + for _, column := range []string{"identifier_type", "identifier_value", "participant_id"} { + assert.Contains(columns, column, "the existing anchor columns must be untouched") + } +} + +func TestInitSchemaAddsObservationConflictOriginToExistingCandidateTable(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := t.Context() + + for _, column := range []string{"observation_conflict_origin", "pre_conflict_state"} { + _, err := st.DB().ExecContext( + ctx, "ALTER TABLE identity_match_candidates DROP COLUMN "+column, + ) + require.NoError(err) + } + require.NoError(st.InitSchemaContext(ctx)) + + columns := liveTableColumns(t, st, "identity_match_candidates") + assert.Contains(columns, "observation_conflict_origin") + assert.Contains(columns, "pre_conflict_state") +} + +func TestProfileReadsSucceedOnTheConfiguredBackend(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + + services, err := st.ListCommunicationServicesContext(ctx, true) + require.NoError(err, "communication_services and its alias table") + assert.GreaterOrEqual(len(services), 24) + + personID := newTestPerson(t, st) + profile, err := st.GetPersonProfileContext(ctx, personID) + require.NoError(err, "person profile value tables") + assert.Empty(profile.Names) + + history, err := st.GetPersonProfileHistoryContext(ctx, personID) + require.NoError(err, "participant_contact_observations") + assert.Empty(history.Observations) + + candidates, err := st.ListIdentityMatchCandidatesContext(ctx, nil, 10, 0) + require.NoError(err, "identity match candidates and evidence") + assert.Empty(candidates) +} + +func TestFullProfileLifecycleOnConfiguredBackend(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + + participantID, err := st.EnsureParticipantByIdentifier( + "email", "alice@example.com", "Alice Example", + ) + require.NoError(err, "EnsureParticipantByIdentifier") + person, _, err := st.CreatePersonFromParticipantContext(ctx, participantID) + require.NoError(err, "CreatePersonFromParticipantContext") + seedFullProfile(t, st, person.ID) + + profile, err := st.GetPersonProfileContext(ctx, person.ID) + require.NoError(err, "GetPersonProfileContext") + require.Len(profile.Names, 2) + require.Len(profile.ContactPoints, 2) + require.Len(profile.Media, 1) + + data, mediaType, err := st.ReadPersonMediaDataContext( + ctx, person.ID, profile.Media[0].Envelope.ID, + ) + require.NoError(err, "ReadPersonMediaDataContext") + assert.Equal([]byte("synthetic-photo"), data) + assert.Equal("image/png", mediaType) + + patched, err := st.ApplyPersonProfilePatchContext( + ctx, person.ID, profile.Person.Revision, store.PersonProfilePatch{ + ContactPoints: &store.PersonContactPointPatch{ + Supersede: []int64{profile.ContactPoints[0].Envelope.ID}, + Add: []store.PersonContactPointInput{{ + AddressKind: store.ContactAddressUsername, + ServiceSlug: new("matrix"), + ScopeKind: new("server"), + ScopeValue: new("example.org"), + OriginalValue: "@Alice:example.org", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }}, + }, + }, + ) + require.NoError(err, "ApplyPersonProfilePatchContext") + assert.Equal(profile.Person.Revision+1, patched.Person.Revision) + require.Len(patched.ContactPoints, 2) + assert.Equal("@Alice:example.org", patched.ContactPoints[1].NormalizedValue) + + bobID, err := st.EnsureParticipantByIdentifier( + "beeper", "@bob:example.org", "Bob Example", + ) + require.NoError(err, "EnsureParticipantByIdentifier bob") + conflicting, err := st.RecordContactObservationContext( + ctx, bobID, store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressUsername, + ServiceSlug: new("x"), + ProviderUserID: new("x-bob"), + OriginalValue: "@shared", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + }, + ) + require.NoError(err, "first observation") + assert.False(conflicting.Conflicting) + + second, err := st.RecordContactObservationContext( + ctx, participantID, store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressUsername, + ServiceSlug: new("x"), + ProviderUserID: new("x-alice"), + OriginalValue: "@shared", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + }, + ) + require.NoError(err, "second observation") + assert.True(second.Conflicting) + + history, err := st.GetPersonProfileHistoryContext(ctx, person.ID) + require.NoError(err, "GetPersonProfileHistoryContext") + assert.Len(history.ContactPoints, 3) + assert.Len(history.Observations, 1) +} + +func liveTableColumns(t *testing.T, st *store.Store, table string) []string { + t.Helper() + require := require.New(t) + + rows, err := st.DB().QueryContext( + context.Background(), "SELECT * FROM "+table+" WHERE 1 = 0", + ) + require.NoError(err, "table %s must exist and be queryable", table) + defer func() { _ = rows.Close() }() + + columns, err := rows.Columns() + require.NoError(err, "read columns of %s", table) + require.NoError(rows.Err(), "iterate columns of %s", table) + sort.Strings(columns) + return columns +} diff --git a/internal/store/person_profile_snapshot_pg_test.go b/internal/store/person_profile_snapshot_pg_test.go new file mode 100644 index 000000000..bf20182ee --- /dev/null +++ b/internal/store/person_profile_snapshot_pg_test.go @@ -0,0 +1,101 @@ +package store_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil/storetest" +) + +func TestPostgreSQLPersonProfileReadsUseOneRevisionSnapshot(t *testing.T) { + for _, history := range []bool{false, true} { + name := "current" + if history { + name = "history" + } + t.Run(name, func(t *testing.T) { + assertPostgreSQLPersonProfileSnapshot(t, history) + }) + } +} + +func assertPostgreSQLPersonProfileSnapshot(t *testing.T, history bool) { + t.Helper() + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + if !st.IsPostgreSQL() { + t.Skip("PostgreSQL snapshot regression") + } + ctx := context.Background() + personID := newTestPerson(t, st) + before, err := st.GetPersonContext(ctx, personID) + require.NoError(err) + + blocker, err := st.DB().BeginTx(ctx, nil) + require.NoError(err) + t.Cleanup(func() { _ = blocker.Rollback() }) + _, err = blocker.ExecContext(ctx, `LOCK TABLE person_contact_points IN ACCESS EXCLUSIVE MODE`) + require.NoError(err) + + type readResult struct { + revision int64 + addresses []store.PersonAddress + err error + } + result := make(chan readResult, 1) + go func() { + if history { + profile, readErr := st.GetPersonProfileHistoryContext(ctx, personID) + if readErr != nil { + result <- readResult{err: readErr} + return + } + result <- readResult{revision: profile.Person.Revision, addresses: profile.Addresses} + return + } + profile, readErr := st.GetPersonProfileContext(ctx, personID) + if readErr != nil { + result <- readResult{err: readErr} + return + } + result <- readResult{revision: profile.Person.Revision, addresses: profile.Addresses} + }() + + var lockErr error + require.Eventually(func() bool { + var waiting int + lockErr = st.DB().QueryRowContext(ctx, `SELECT COUNT(*) + FROM pg_locks l + JOIN pg_class c ON c.oid = l.relation + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = 'person_contact_points' + AND n.nspname = current_schema() + AND l.mode = 'AccessShareLock' + AND NOT l.granted`).Scan(&waiting) + return lockErr == nil && waiting > 0 + }, 5*time.Second, 10*time.Millisecond, "profile read must reach the blocked later table") + require.NoError(lockErr) + + _, err = st.AddPersonAddressContext(ctx, personID, store.PersonAddressInput{ + AddressKind: store.PersonAddressPostal, + StreetAddress: new("123 Example St."), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(err) + require.NoError(blocker.Commit()) + + select { + case read := <-result: + require.NoError(read.err) + assert.Equal(before.Revision, read.revision) + assert.Empty(read.addresses, + "an old person revision must not be paired with a later profile row") + case <-time.After(5 * time.Second): + require.Fail("profile read did not finish after releasing the table lock") + } +} diff --git a/internal/store/person_profile_test.go b/internal/store/person_profile_test.go new file mode 100644 index 000000000..dd335a533 --- /dev/null +++ b/internal/store/person_profile_test.go @@ -0,0 +1,285 @@ +package store_test + +import ( + "context" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil/storetest" +) + +func TestGetPersonProfileReturnsEveryValueKindWithOneRevision(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + personID := newTestPerson(t, st) + seedFullProfile(t, st, personID) + profile, err := st.GetPersonProfileContext(context.Background(), personID) + require.NoError(err) + assert.Equal(personID, profile.Person.ID) + assert.Len(profile.Names, 2) + assert.Len(profile.ContactPoints, 2) + assert.Len(profile.Addresses, 1) + assert.Len(profile.Dates, 1) + assert.Len(profile.Categories, 1) + assert.Len(profile.Media, 1) + assert.Greater(profile.Person.Revision, int64(1), "profile writes bump the person revision") +} + +func TestProfileInputsPreserveExplicitZeroOrdinal(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := t.Context() + personID := newTestPerson(t, st) + first := store.ValueEnvelopeInput{Source: store.ProvenanceUser} + explicitZero := store.ValueEnvelopeInput{Source: store.ProvenanceUser, Ordinal: new(0)} + + _, err := st.AddPersonNameContext(ctx, personID, store.PersonNameInput{ + NameKind: store.PersonNameNickname, Formatted: new("First"), Envelope: first, + }) + require.NoError(err) + name, err := st.AddPersonNameContext(ctx, personID, store.PersonNameInput{ + NameKind: store.PersonNameNickname, Formatted: new("Pinned first"), Envelope: explicitZero, + }) + require.NoError(err) + assert.Equal(0, name.Envelope.Ordinal) + + _, err = st.AddPersonContactPointContext(ctx, personID, store.PersonContactPointInput{ + AddressKind: store.ContactAddressEmail, OriginalValue: "first@example.com", Envelope: first, + }) + require.NoError(err) + point, err := st.AddPersonContactPointContext(ctx, personID, store.PersonContactPointInput{ + AddressKind: store.ContactAddressEmail, OriginalValue: "pinned@example.com", Envelope: explicitZero, + }) + require.NoError(err) + assert.Equal(0, point.Envelope.Ordinal) + + _, err = st.AddPersonAddressContext(ctx, personID, store.PersonAddressInput{ + AddressKind: store.PersonAddressPostal, StreetAddress: new("First"), Envelope: first, + }) + require.NoError(err) + address, err := st.AddPersonAddressContext(ctx, personID, store.PersonAddressInput{ + AddressKind: store.PersonAddressPostal, StreetAddress: new("Pinned"), Envelope: explicitZero, + }) + require.NoError(err) + assert.Equal(0, address.Envelope.Ordinal) + + _, err = st.AddPersonDateContext(ctx, personID, store.PersonDateInput{ + DateKind: store.PersonDateCustom, DateText: new("First"), Envelope: first, + }) + require.NoError(err) + date, err := st.AddPersonDateContext(ctx, personID, store.PersonDateInput{ + DateKind: store.PersonDateCustom, DateText: new("Pinned"), Envelope: explicitZero, + }) + require.NoError(err) + assert.Equal(0, date.Envelope.Ordinal) + + _, err = st.AddPersonCategoryContext(ctx, personID, store.PersonCategoryInput{ + OriginalValue: "First", Envelope: first, + }) + require.NoError(err) + category, err := st.AddPersonCategoryContext(ctx, personID, store.PersonCategoryInput{ + OriginalValue: "Pinned", Envelope: explicitZero, + }) + require.NoError(err) + assert.Equal(0, category.Envelope.Ordinal) + + _, err = st.AddPersonMediaContext(ctx, personID, store.PersonMediaInput{ + MediaKind: store.PersonMediaPhoto, URI: new("https://example.invalid/first"), Envelope: first, + }) + require.NoError(err) + media, err := st.AddPersonMediaContext(ctx, personID, store.PersonMediaInput{ + MediaKind: store.PersonMediaPhoto, URI: new("https://example.invalid/pinned"), Envelope: explicitZero, + }) + require.NoError(err) + assert.Equal(0, media.Envelope.Ordinal) + + _, err = st.AddPersonNameContext(ctx, personID, store.PersonNameInput{ + NameKind: store.PersonNameSort, SortAs: new("Invalid"), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser, Ordinal: new(-1)}, + }) + assert.ErrorIs(err, store.ErrInvalidProfileOrdinal) +} + +func TestApplyPersonProfilePatchIsAtomicUnderRevision(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + personID := newTestPerson(t, st) + person, err := st.GetPersonContext(ctx, personID) + require.NoError(err) + patched, err := st.ApplyPersonProfilePatchContext(ctx, personID, person.Revision, store.PersonProfilePatch{ + Names: &store.PersonNamePatch{Add: []store.PersonNameInput{{ + NameKind: store.PersonNameFormatted, Formatted: new("Alice Example"), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }}}, + ContactPoints: &store.PersonContactPointPatch{Add: []store.PersonContactPointInput{{ + AddressKind: store.ContactAddressEmail, OriginalValue: "Alice@Example.com", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }}}, + }) + require.NoError(err) + assert.Equal(person.Revision+1, patched.Person.Revision) + assert.Len(patched.Names, 1) + assert.Len(patched.ContactPoints, 1) + _, err = st.ApplyPersonProfilePatchContext(ctx, personID, person.Revision, store.PersonProfilePatch{ + Names: &store.PersonNamePatch{Add: []store.PersonNameInput{{ + NameKind: store.PersonNameNickname, Formatted: new("Ally"), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }}}, + }) + require.ErrorIs(err, store.ErrPersonRevisionConflict) +} + +func TestFailedPatchRollsBackEveryCollection(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + personID := newTestPerson(t, st) + person, err := st.GetPersonContext(ctx, personID) + require.NoError(err) + _, err = st.ApplyPersonProfilePatchContext(ctx, personID, person.Revision, store.PersonProfilePatch{ + Names: &store.PersonNamePatch{Add: []store.PersonNameInput{{ + NameKind: store.PersonNameFormatted, Formatted: new("Alice Example"), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }}}, + ContactPoints: &store.PersonContactPointPatch{Add: []store.PersonContactPointInput{{ + AddressKind: store.ContactAddressUsername, ServiceSlug: new("slack"), + OriginalValue: "alice", Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }}}, + }) + require.ErrorIs(err, store.ErrServiceScopeRequired) + profile, err := st.GetPersonProfileContext(ctx, personID) + require.NoError(err) + assert.Empty(profile.Names) + assert.Equal(person.Revision, profile.Person.Revision) +} + +func TestPatchSupersedeMovesValuesIntoHistoryOnly(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + personID := newTestPerson(t, st) + seedFullProfile(t, st, personID) + before, err := st.GetPersonProfileContext(ctx, personID) + require.NoError(err) + after, err := st.ApplyPersonProfilePatchContext(ctx, personID, before.Person.Revision, store.PersonProfilePatch{ + ContactPoints: &store.PersonContactPointPatch{ + Supersede: []int64{before.ContactPoints[0].Envelope.ID}, + }, + }) + require.NoError(err) + assert.Len(after.ContactPoints, 1) + history, err := st.GetPersonProfileHistoryContext(ctx, personID) + require.NoError(err) + assert.Len(history.ContactPoints, 2) +} + +func TestPatchRejectsEmptyAndOversizedRequests(t *testing.T) { + require := require.New(t) + st := storetest.New(t).Store + ctx := context.Background() + personID := newTestPerson(t, st) + person, err := st.GetPersonContext(ctx, personID) + require.NoError(err) + _, err = st.ApplyPersonProfilePatchContext(ctx, personID, person.Revision, store.PersonProfilePatch{}) + require.ErrorIs(err, store.ErrPersonProfilePatchEmpty) + adds := make([]store.PersonCategoryInput, store.MaxPersonProfilePatchOperations+1) + for i := range adds { + adds[i] = store.PersonCategoryInput{ + OriginalValue: "tag-" + strconv.Itoa(i), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + } + } + _, err = st.ApplyPersonProfilePatchContext(ctx, personID, person.Revision, store.PersonProfilePatch{ + Categories: &store.PersonCategoryPatch{Add: adds}, + }) + require.ErrorIs(err, store.ErrPersonProfilePatchTooLarge) +} + +func TestGetPersonProfileHistoryIncludesParticipantObservations(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + participantID, err := st.EnsureParticipantByIdentifier( + "email", "alice@example.com", "Alice Example", + ) + require.NoError(err) + person, _, err := st.CreatePersonFromParticipantContext(ctx, participantID) + require.NoError(err) + _, err = st.RecordContactObservationContext(ctx, participantID, store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressUsername, ServiceSlug: new("x"), + OriginalValue: "@alice", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + }) + require.NoError(err) + history, err := st.GetPersonProfileHistoryContext(ctx, person.ID) + require.NoError(err) + require.Len(history.Observations, 1) + assert.Equal("alice", history.Observations[0].NormalizedValue) + assert.Equal(participantID, history.Observations[0].ParticipantID) + + profile, err := st.GetPersonProfileContext(ctx, person.ID) + require.NoError(err) + assert.Empty(profile.ContactPoints, + "an archive observation is not curated reachability and must not appear as one") +} + +func TestGetPersonProfileRejectsUnknownPerson(t *testing.T) { + st := storetest.New(t).Store + + _, err := st.GetPersonProfileContext(context.Background(), 999999) + assert.ErrorIs(t, err, store.ErrPersonNotFound) +} + +func seedFullProfile(t *testing.T, st *store.Store, personID int64) { + t.Helper() + require := require.New(t) + ctx := context.Background() + for _, input := range []store.PersonNameInput{ + {NameKind: store.PersonNameFormatted, Formatted: new("Alice Example"), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}}, + {NameKind: store.PersonNameStructured, FamilyName: new("Example"), + GivenName: new("Alice"), Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}}, + } { + _, err := st.AddPersonNameContext(ctx, personID, input) + require.NoError(err) + } + for _, input := range []store.PersonContactPointInput{ + {AddressKind: store.ContactAddressEmail, OriginalValue: "alice@example.com", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}}, + {AddressKind: store.ContactAddressPhone, ServiceSlug: new("whatsapp"), + OriginalValue: "+12025550123", Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}}, + } { + _, err := st.AddPersonContactPointContext(ctx, personID, input) + require.NoError(err) + } + _, err := st.AddPersonAddressContext(ctx, personID, store.PersonAddressInput{ + AddressKind: store.PersonAddressPostal, StreetAddress: new("123 Example St."), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(err) + _, err = st.AddPersonDateContext(ctx, personID, store.PersonDateInput{ + DateKind: store.PersonDateBirthday, Date: partialDate(0, 4, 12), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(err) + _, err = st.AddPersonCategoryContext(ctx, personID, store.PersonCategoryInput{ + OriginalValue: "Friends", Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(err) + _, err = st.AddPersonMediaContext(ctx, personID, store.PersonMediaInput{ + MediaKind: store.PersonMediaPhoto, MediaType: new("image/png"), + Data: []byte("synthetic-photo"), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(err) +} diff --git a/internal/store/persons.go b/internal/store/persons.go index 78c44cd98..d81d96ea6 100644 --- a/internal/store/persons.go +++ b/internal/store/persons.go @@ -189,6 +189,21 @@ func (s *Store) DeletePersonContext(ctx context.Context, id, expectedRevision in if references > 0 { return fmt.Errorf("delete person %d: %w", id, ErrPersonReferenced) } + if _, err := tx.ExecContext(ctx, `DELETE FROM identity_match_candidates + WHERE (left_kind = ? AND left_id = ?) + OR (right_kind = ? AND right_id = ?) + OR (left_kind = ? AND left_id IN ( + SELECT id FROM person_contact_points WHERE person_id = ? + )) + OR (right_kind = ? AND right_id IN ( + SELECT id FROM person_contact_points WHERE person_id = ? + ))`, + IdentityMatchPerson, id, IdentityMatchPerson, id, + IdentityMatchContactPoint, id, + IdentityMatchContactPoint, id, + ); err != nil { + return fmt.Errorf("delete identity match candidates for person %d: %w", id, err) + } var deletedID int64 err = tx.QueryRowContext(ctx, `DELETE FROM persons WHERE id = ? AND revision = ? RETURNING id`, diff --git a/internal/store/pg_maintenance_internal_test.go b/internal/store/pg_maintenance_internal_test.go index ce41e5bfc..dd9325de8 100644 --- a/internal/store/pg_maintenance_internal_test.go +++ b/internal/store/pg_maintenance_internal_test.go @@ -141,6 +141,15 @@ func TestExclusiveLockTablesCoverCascade(t *testing.T) { sort.Strings(missing) assert.Empty(missing, "every ON DELETE CASCADE-to-sources table must be in exclusiveLockTables; missing: %v", missing) + + // Source removal explicitly deletes identity candidates whose polymorphic + // observation endpoints belong to the source. Evidence then cascades from + // those candidates. Both tables are part of the serialized delete's write + // set even though neither has a direct foreign key to sources. + assert.True(lockSet["identity_match_candidates"], + "identity_match_candidates must be locked for source observation cleanup") + assert.True(lockSet["identity_match_evidence"], + "identity_match_evidence must be locked for candidate cascade cleanup") } // TestMaintenanceTimeoutResetSQL pins the exact statement the PG dialect uses diff --git a/internal/store/profile_identity_concurrency_pg_test.go b/internal/store/profile_identity_concurrency_pg_test.go new file mode 100644 index 000000000..c0cadf6d9 --- /dev/null +++ b/internal/store/profile_identity_concurrency_pg_test.go @@ -0,0 +1,349 @@ +package store_test + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil/storetest" +) + +const concurrentInsertCalls = 8 + +func TestPostgreSQLMergeSerializesWithObservationInsert(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + if !st.IsPostgreSQL() { + t.Skip("PostgreSQL concurrency regression") + } + ctx := t.Context() + absorbed, err := st.EnsureParticipantByIdentifier( + "example", "merge-observation-absorbed", "Absorbed", + ) + require.NoError(err) + survivor, err := st.EnsureParticipantByIdentifier( + "example", "merge-observation-survivor", "Survivor", + ) + require.NoError(err) + + const advisoryKey int64 = 88442211 + barrier, err := st.DB().Conn(ctx) + require.NoError(err) + t.Cleanup(func() { + _, _ = barrier.ExecContext(context.Background(), "SELECT pg_advisory_unlock($1)", advisoryKey) + _ = barrier.Close() + }) + _, err = barrier.ExecContext(ctx, "SELECT pg_advisory_lock($1)", advisoryKey) + require.NoError(err) + _, err = st.DB().ExecContext(ctx, fmt.Sprintf(` + CREATE FUNCTION delay_merge_observation_insert_fn() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + PERFORM pg_advisory_xact_lock(%d); + RETURN NEW; + END + $$; + CREATE TRIGGER delay_merge_observation_insert + BEFORE INSERT ON participant_contact_observations + FOR EACH ROW EXECUTE FUNCTION delay_merge_observation_insert_fn()`, advisoryKey)) + require.NoError(err) + + recordDone := make(chan error, 1) + go func() { + _, recordErr := st.RecordContactObservationContext(ctx, absorbed, + store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressEmail, OriginalValue: "merge@example.com", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + }) + recordDone <- recordErr + }() + require.Eventually(func() bool { + return postgreSQLWaitingLockCount(t, st) >= 1 + }, 5*time.Second, 10*time.Millisecond, "observation insert did not reach its barrier") + + mergeDone := make(chan error, 1) + go func() { mergeDone <- st.MergeParticipants(absorbed, survivor) }() + var mergeErr error + mergeFinished := false + require.Eventually(func() bool { + select { + case mergeErr = <-mergeDone: + mergeFinished = true + return true + default: + return postgreSQLWaitingLockCount(t, st) >= 2 + } + }, 5*time.Second, 10*time.Millisecond, "merge did not serialize behind observation recording") + + _, err = barrier.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", advisoryKey) + require.NoError(err) + require.NoError(<-recordDone) + if !mergeFinished { + mergeErr = <-mergeDone + } + require.NoError(mergeErr) + + observations, err := st.ListParticipantObservationsContext(ctx, survivor, true) + require.NoError(err) + require.Len(observations, 1) + assert.Equal("merge@example.com", observations[0].NormalizedValue) +} + +func postgreSQLWaitingLockCount(t *testing.T, st *store.Store) int { + t.Helper() + var count int + require.NoError(t, st.DB().QueryRowContext(t.Context(), ` + SELECT COUNT(*) FROM pg_stat_activity + WHERE datname = current_database() AND wait_event_type = 'Lock'`).Scan(&count)) + return count +} + +func TestPostgreSQLConcurrentParticipantObservationInsertIsIdempotent(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + if !st.IsPostgreSQL() { + t.Skip("PostgreSQL concurrency regression") + } + ctx := context.Background() + participantID, err := st.EnsureParticipantByIdentifier( + "example", "participant-1", "Test User", + ) + require.NoError(err) + installPostgreSQLInsertDelay(t, st, "participant_contact_observations", "delay_observation_insert") + + input := store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressEmail, + OriginalValue: "user@example.com", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + } + type callResult struct { + result *store.RecordContactObservationResult + err error + } + results := make([]callResult, concurrentInsertCalls) + start := make(chan struct{}) + var wait sync.WaitGroup + for i := range results { + wait.Go(func() { + <-start + results[i].result, results[i].err = st.RecordContactObservationContext( + ctx, participantID, input, + ) + }) + } + close(start) + wait.Wait() + + created := 0 + ids := make(map[int64]struct{}) + for _, result := range results { + require.NoError(result.err) + require.NotNil(result.result) + if result.result.Created { + created++ + } + ids[result.result.Observation.Envelope.ID] = struct{}{} + } + assert.Equal(1, created) + assert.Len(ids, 1) + assert.Equal(1, tableRowCount(t, st, "participant_contact_observations")) +} + +func TestPostgreSQLConcurrentIdentityMatchCandidateInsertIsIdempotent(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + if !st.IsPostgreSQL() { + t.Skip("PostgreSQL concurrency regression") + } + ctx := context.Background() + leftID, err := st.EnsureParticipantByIdentifier("example", "left", "Left User") + require.NoError(err) + rightID, err := st.EnsureParticipantByIdentifier("example", "right", "Right User") + require.NoError(err) + installPostgreSQLInsertDelay(t, st, "identity_match_candidates", "delay_candidate_insert") + + input := store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchParticipant, + LeftID: leftID, + RightKind: store.IdentityMatchParticipant, + RightID: rightID, + Basis: store.IdentityMatchEmail, + State: store.IdentityMatchStateCandidate, + Source: store.ProvenanceArchiveObservation, + } + type callResult struct { + candidate *store.IdentityMatchCandidate + created bool + err error + } + results := make([]callResult, concurrentInsertCalls) + start := make(chan struct{}) + var wait sync.WaitGroup + for i := range results { + wait.Go(func() { + <-start + results[i].candidate, results[i].created, results[i].err = + st.UpsertIdentityMatchCandidateContext(ctx, input) + }) + } + close(start) + wait.Wait() + + created := 0 + ids := make(map[int64]struct{}) + for _, result := range results { + require.NoError(result.err) + require.NotNil(result.candidate) + if result.created { + created++ + } + ids[result.candidate.ID] = struct{}{} + } + assert.Equal(1, created) + assert.Len(ids, 1) + assert.Equal(1, tableRowCount(t, st, "identity_match_candidates")) +} + +func TestPostgreSQLConcurrentCommunicationServiceSlugIsIdempotent(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + if !st.IsPostgreSQL() { + t.Skip("PostgreSQL concurrency regression") + } + installPostgreSQLInsertDelay(t, st, "communication_services", "delay_service_insert") + + input := store.CommunicationServiceInput{ + Slug: "example-bridge", DisplayLabel: "Example Bridge", + ScopePolicy: store.ScopePolicyNone, + Normalization: store.NormalizationLower, NormalizationVersion: 1, + } + type callResult struct { + service *store.CommunicationService + created bool + err error + } + results := make([]callResult, concurrentInsertCalls) + start := make(chan struct{}) + var wait sync.WaitGroup + for i := range results { + wait.Go(func() { + <-start + results[i].service, results[i].created, results[i].err = + st.EnsureCommunicationServiceContext(t.Context(), input) + }) + } + close(start) + wait.Wait() + + created := 0 + ids := make(map[int64]struct{}) + for _, result := range results { + require.NoError(result.err) + require.NotNil(result.service) + if result.created { + created++ + } + ids[result.service.ID] = struct{}{} + } + assert.Equal(1, created) + assert.Len(ids, 1) + assert.Equal(1, serviceSlugCount(t, st, "example-bridge")) +} + +func TestPostgreSQLConcurrentCommunicationServiceAliasClaimHasStableConflict(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + if !st.IsPostgreSQL() { + t.Skip("PostgreSQL concurrency regression") + } + installPostgreSQLInsertDelay(t, st, "communication_service_aliases", "delay_service_alias_insert") + + inputs := []store.CommunicationServiceInput{ + { + Slug: "example-bridge-left", DisplayLabel: "Example Bridge Left", + Aliases: []string{"shared-example-alias"}, ScopePolicy: store.ScopePolicyNone, + Normalization: store.NormalizationLower, NormalizationVersion: 1, + }, + { + Slug: "example-bridge-right", DisplayLabel: "Example Bridge Right", + Aliases: []string{"shared-example-alias"}, ScopePolicy: store.ScopePolicyNone, + Normalization: store.NormalizationLower, NormalizationVersion: 1, + }, + } + type callResult struct { + service *store.CommunicationService + err error + } + results := make([]callResult, len(inputs)) + start := make(chan struct{}) + var wait sync.WaitGroup + for i := range inputs { + wait.Go(func() { + <-start + results[i].service, _, results[i].err = + st.EnsureCommunicationServiceContext(t.Context(), inputs[i]) + }) + } + close(start) + wait.Wait() + + succeeded := 0 + conflicted := 0 + for _, result := range results { + if result.err == nil { + succeeded++ + require.NotNil(result.service) + continue + } + if assert.ErrorIs(result.err, store.ErrServiceAliasConflict) { + conflicted++ + } + } + assert.Equal(1, succeeded) + assert.Equal(1, conflicted) + resolved, err := st.ResolveCommunicationServiceContext(t.Context(), "shared-example-alias") + require.NoError(err) + assert.Contains([]string{"example-bridge-left", "example-bridge-right"}, resolved.Slug) +} + +func installPostgreSQLInsertDelay(t *testing.T, st *store.Store, table, trigger string) { + t.Helper() + function := trigger + "_fn" + _, err := st.DB().ExecContext(t.Context(), fmt.Sprintf(` + CREATE FUNCTION %s() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + PERFORM pg_sleep(0.5); + RETURN NEW; + END + $$; + CREATE TRIGGER %s BEFORE INSERT ON %s + FOR EACH ROW EXECUTE FUNCTION %s()`, function, trigger, table, function)) + require.NoError(t, err) +} + +func tableRowCount(t *testing.T, st *store.Store, table string) int { + t.Helper() + var count int + require.NoError(t, st.DB().QueryRowContext(t.Context(), + "SELECT COUNT(*) FROM "+table, + ).Scan(&count)) + return count +} + +func serviceSlugCount(t *testing.T, st *store.Store, slug string) int { + t.Helper() + var count int + require.NoError(t, st.DB().QueryRowContext(t.Context(), st.Rebind( + "SELECT COUNT(*) FROM communication_services WHERE slug = ?"), slug, + ).Scan(&count)) + return count +} diff --git a/internal/store/profile_identity_lock.go b/internal/store/profile_identity_lock.go new file mode 100644 index 000000000..67aa4d2a7 --- /dev/null +++ b/internal/store/profile_identity_lock.go @@ -0,0 +1,46 @@ +package store + +import ( + "context" + "fmt" + "strconv" + "strings" +) + +// lockProfileIdentityKeyTxContext serializes a check-then-insert for one +// logical profile-identity key. PostgreSQL row locks cannot lock an absent +// row, and its ordinary unique indexes treat NULL values as distinct. A +// transaction-scoped advisory lock closes that gap without changing the +// duplicate-tolerant API contract. SQLite has a single writer, so taking the +// existing identity-mutation write lock before the read provides the same +// ordering there. +func (s *Store) lockProfileIdentityKeyTxContext( + ctx context.Context, + tx *loggedTx, + namespace string, + parts ...any, +) error { + if !s.IsPostgreSQL() { + return s.lockIdentityMutationTxContext(ctx, tx) + } + + var key strings.Builder + key.WriteString(namespace) + for _, part := range parts { + rendered := fmt.Sprintf("%v", part) + partType := fmt.Sprintf("%T", part) + key.WriteByte('|') + key.WriteString(partType) + key.WriteByte(':') + key.WriteString(strconv.Itoa(len(rendered))) + key.WriteByte(':') + key.WriteString(rendered) + } + if _, err := tx.ExecContext(ctx, + `SELECT pg_advisory_xact_lock(hashtextextended(CAST(? AS TEXT), 0))`, + key.String(), + ); err != nil { + return fmt.Errorf("lock %s identity key: %w", namespace, err) + } + return nil +} diff --git a/internal/store/profile_ordinals_test.go b/internal/store/profile_ordinals_test.go new file mode 100644 index 000000000..ae95f470d --- /dev/null +++ b/internal/store/profile_ordinals_test.go @@ -0,0 +1,80 @@ +package store_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil/storetest" +) + +func TestAutomaticProfileOrdinalsNeverReuseHistoricalSlots(t *testing.T) { + t.Run("kind-scoped values", func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + personID := newTestPerson(t, st) + + var points []*store.PersonContactPoint + for _, email := range []string{"first@example.org", "second@example.org"} { + point, err := st.AddPersonContactPointContext( + t.Context(), personID, store.PersonContactPointInput{ + AddressKind: store.ContactAddressEmail, OriginalValue: email, + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }, + ) + require.NoError(err) + points = append(points, point) + } + assert.Equal(0, points[0].Envelope.Ordinal) + assert.Equal(1, points[1].Envelope.Ordinal) + require.NoError(st.SupersedePersonContactPointContext( + t.Context(), personID, points[1].Envelope.ID, nil, + )) + + appended, err := st.AddPersonContactPointContext( + t.Context(), personID, store.PersonContactPointInput{ + AddressKind: store.ContactAddressEmail, OriginalValue: "third@example.org", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }, + ) + require.NoError(err) + assert.Equal(2, appended.Envelope.Ordinal, + "append must not adopt a superseded value's history slot") + }) + + t.Run("owner-scoped values", func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + personID := newTestPerson(t, st) + + var categories []*store.PersonCategory + for _, category := range []string{"First", "Second"} { + value, err := st.AddPersonCategoryContext( + t.Context(), personID, store.PersonCategoryInput{ + OriginalValue: category, + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }, + ) + require.NoError(err) + categories = append(categories, value) + } + assert.Equal(0, categories[0].Envelope.Ordinal) + assert.Equal(1, categories[1].Envelope.Ordinal) + require.NoError(st.SupersedePersonCategoryContext( + t.Context(), personID, categories[1].Envelope.ID, nil, + )) + + appended, err := st.AddPersonCategoryContext( + t.Context(), personID, store.PersonCategoryInput{ + OriginalValue: "Third", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }, + ) + require.NoError(err) + assert.Equal(2, appended.Envelope.Ordinal, + "append must not adopt a superseded value's history slot") + }) +} diff --git a/internal/store/profile_store_helpers.go b/internal/store/profile_store_helpers.go new file mode 100644 index 000000000..d96e2be03 --- /dev/null +++ b/internal/store/profile_store_helpers.go @@ -0,0 +1,168 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +func ensureProfilePersonTx(ctx context.Context, tx *loggedTx, personID int64) error { + var exists int + if err := tx.QueryRowContext(ctx, + `SELECT COUNT(*) FROM persons WHERE id = ?`, personID, + ).Scan(&exists); err != nil { + return fmt.Errorf("check person: %w", err) + } + if exists == 0 { + return ErrPersonNotFound + } + return nil +} + +func nextProfileOrdinalForOwnerTx( + ctx context.Context, + tx *loggedTx, + table, ownerColumn, kindColumn string, + ownerID int64, + kind any, +) (int, error) { + var ordinal int + query := fmt.Sprintf(`SELECT COALESCE(MAX(ordinal) + 1, 0) + FROM %s WHERE %s = ?`, table, ownerColumn) + args := []any{ownerID} + if kindColumn != "" { + query += fmt.Sprintf(` AND %s = ?`, kindColumn) + args = append(args, kind) + } + // Scan historical rows too: reusing a superseded slot's ordinal would + // splice an unrelated value into that slot's supersession lineage. + if err := tx.QueryRowContext(ctx, query, args...).Scan(&ordinal); err != nil { + return 0, fmt.Errorf("choose %s ordinal: %w", table, err) + } + return ordinal, nil +} + +func resolveProfileEnvelopeForOwnerTx( + ctx context.Context, + tx *loggedTx, + table, ownerColumn, kindColumn string, + ownerID int64, + kind any, + input ValueEnvelopeInput, +) (ValueEnvelope, error) { + if err := input.Validate(); err != nil { + return ValueEnvelope{}, err + } + if input.Ordinal != nil { + return input.valueEnvelope(*input.Ordinal), nil + } + ordinal, err := nextProfileOrdinalForOwnerTx( + ctx, tx, table, ownerColumn, kindColumn, ownerID, kind, + ) + if err != nil { + return ValueEnvelope{}, err + } + return input.valueEnvelope(ordinal), nil +} + +func resolveProfileEnvelopeTx( + ctx context.Context, + tx *loggedTx, + table, kindColumn string, + personID int64, + kind any, + input ValueEnvelopeInput, +) (ValueEnvelope, error) { + return resolveProfileEnvelopeForOwnerTx( + ctx, tx, table, "person_id", kindColumn, personID, kind, input, + ) +} + +func (s *Store) supersedeProfileValueTx( + ctx context.Context, + tx *loggedTx, + table string, + personID, valueID int64, + activeUntil *time.Time, +) error { + if err := s.validateProfileValueCloseTimeTx( + ctx, tx, table, "person_id", personID, valueID, activeUntil, + ); err != nil { + return err + } + query := fmt.Sprintf(`UPDATE %s + SET active_until = COALESCE(active_until, ?, + CASE WHEN active_from > %s THEN active_from ELSE %s END), + superseded_at = %s, + updated_at = %s + WHERE id = ? AND person_id = ? + AND superseded_at IS NULL`, + table, s.dialect.Now(), s.dialect.Now(), s.dialect.Now(), s.dialect.Now(), + ) + result, err := tx.ExecContext(ctx, query, timeValue(activeUntil), valueID, personID) + if err != nil { + return fmt.Errorf("supersede %s value: %w", table, err) + } + changed, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("check superseded %s value: %w", table, err) + } + if changed == 0 { + return ErrProfileValueNotFound + } + return nil +} + +func (s *Store) validateProfileValueCloseTimeTx( + ctx context.Context, + tx *loggedTx, + table, ownerColumn string, + ownerID, valueID int64, + activeUntil *time.Time, +) error { + query := fmt.Sprintf(`SELECT active_from, active_until FROM %s + WHERE id = ? AND %s = ? + AND superseded_at IS NULL%s`, + table, ownerColumn, s.dialect.SelectForUpdate(), + ) + var activeFrom, existingActiveUntil sql.NullTime + err := tx.QueryRowContext(ctx, query, valueID, ownerID).Scan( + &activeFrom, &existingActiveUntil, + ) + if errors.Is(err, sql.ErrNoRows) { + return ErrProfileValueNotFound + } + if err != nil { + return fmt.Errorf("read %s active_from: %w", table, err) + } + if !existingActiveUntil.Valid && activeUntil != nil && + activeFrom.Valid && activeUntil.Before(activeFrom.Time) { + return ErrProfileValueCloseBeforeActive + } + return nil +} + +func queryProfileRowsTx[T any]( + ctx context.Context, + tx *loggedTx, + query string, + scan func(scanner) (*T, error), + args ...any, +) ([]T, error) { + rows, err := tx.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + values := make([]T, 0) + for rows.Next() { + value, err := scan(rows) + if err != nil { + return nil, err + } + values = append(values, *value) + } + return values, rows.Err() +} diff --git a/internal/store/profile_supersede_time_test.go b/internal/store/profile_supersede_time_test.go new file mode 100644 index 000000000..c3457507f --- /dev/null +++ b/internal/store/profile_supersede_time_test.go @@ -0,0 +1,248 @@ +package store_test + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil/storetest" +) + +func TestSupersedeDefaultsFutureDatedProfileCloseToActiveFrom(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := t.Context() + personID := newTestPerson(t, st) + activeFrom := time.Date(2099, 6, 2, 12, 0, 0, 0, time.UTC) + envelope := store.ValueEnvelopeInput{Source: store.ProvenanceUser, ActiveFrom: &activeFrom} + + name, err := st.AddPersonNameContext(ctx, personID, store.PersonNameInput{ + NameKind: store.PersonNameFormatted, Formatted: new("Future Name"), Envelope: envelope, + }) + require.NoError(err) + point, err := st.AddPersonContactPointContext(ctx, personID, store.PersonContactPointInput{ + AddressKind: store.ContactAddressEmail, OriginalValue: "future@example.org", Envelope: envelope, + }) + require.NoError(err) + address, err := st.AddPersonAddressContext(ctx, personID, store.PersonAddressInput{ + AddressKind: store.PersonAddressPostal, StreetAddress: new("Future Street"), Envelope: envelope, + }) + require.NoError(err) + date, err := st.AddPersonDateContext(ctx, personID, store.PersonDateInput{ + DateKind: store.PersonDateBirthday, Date: partialDate(2099, 6, 2), Envelope: envelope, + }) + require.NoError(err) + category, err := st.AddPersonCategoryContext(ctx, personID, store.PersonCategoryInput{ + OriginalValue: "Future Category", Envelope: envelope, + }) + require.NoError(err) + media, err := st.AddPersonMediaContext(ctx, personID, store.PersonMediaInput{ + MediaKind: store.PersonMediaPhoto, Data: []byte("future-photo"), Envelope: envelope, + }) + require.NoError(err) + + values := []struct { + table string + id int64 + close func() error + }{ + {"person_names", name.Envelope.ID, func() error { + return st.SupersedePersonNameContext(ctx, personID, name.Envelope.ID, nil) + }}, + {"person_contact_points", point.Envelope.ID, func() error { + return st.SupersedePersonContactPointContext(ctx, personID, point.Envelope.ID, nil) + }}, + {"person_addresses", address.Envelope.ID, func() error { + return st.SupersedePersonAddressContext(ctx, personID, address.Envelope.ID, nil) + }}, + {"person_dates", date.Envelope.ID, func() error { + return st.SupersedePersonDateContext(ctx, personID, date.Envelope.ID, nil) + }}, + {"person_categories", category.Envelope.ID, func() error { + return st.SupersedePersonCategoryContext(ctx, personID, category.Envelope.ID, nil) + }}, + {"person_media", media.Envelope.ID, func() error { + return st.SupersedePersonMediaContext(ctx, personID, media.Envelope.ID, nil) + }}, + } + for _, value := range values { + require.NoError(value.close(), value.table) + var activeUntil sql.NullTime + err := st.DB().QueryRow( + st.Rebind("SELECT active_until FROM "+value.table+" WHERE id = ?"), value.id, + ).Scan(&activeUntil) + require.NoError(err, value.table) + require.True(activeUntil.Valid, value.table) + assert.True(activeFrom.Equal(activeUntil.Time), value.table) + } +} + +func TestSupersedeRejectsCloseBeforeStoredActiveFrom(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + personID := newTestPerson(t, st) + activeFrom := time.Date(2025, 6, 2, 12, 0, 0, 0, time.UTC) + tooEarly := activeFrom.Add(-time.Second) + envelope := store.ValueEnvelopeInput{Source: store.ProvenanceUser, ActiveFrom: &activeFrom} + + name, err := st.AddPersonNameContext(ctx, personID, store.PersonNameInput{ + NameKind: store.PersonNameFormatted, Formatted: new("Alice Example"), Envelope: envelope, + }) + require.NoError(err) + point, err := st.AddPersonContactPointContext(ctx, personID, store.PersonContactPointInput{ + AddressKind: store.ContactAddressEmail, OriginalValue: "alice@example.com", Envelope: envelope, + }) + require.NoError(err) + address, err := st.AddPersonAddressContext(ctx, personID, store.PersonAddressInput{ + AddressKind: store.PersonAddressPostal, StreetAddress: new("123 Example St."), Envelope: envelope, + }) + require.NoError(err) + date, err := st.AddPersonDateContext(ctx, personID, store.PersonDateInput{ + DateKind: store.PersonDateBirthday, Date: partialDate(1985, 4, 12), Envelope: envelope, + }) + require.NoError(err) + category, err := st.AddPersonCategoryContext(ctx, personID, store.PersonCategoryInput{ + OriginalValue: "Friends", Envelope: envelope, + }) + require.NoError(err) + media, err := st.AddPersonMediaContext(ctx, personID, store.PersonMediaInput{ + MediaKind: store.PersonMediaPhoto, Data: []byte("synthetic-photo"), Envelope: envelope, + }) + require.NoError(err) + + closers := []struct { + name string + close func() error + }{ + {"name", func() error { return st.SupersedePersonNameContext(ctx, personID, name.Envelope.ID, &tooEarly) }}, + {"contact point", func() error { + return st.SupersedePersonContactPointContext(ctx, personID, point.Envelope.ID, &tooEarly) + }}, + {"address", func() error { return st.SupersedePersonAddressContext(ctx, personID, address.Envelope.ID, &tooEarly) }}, + {"date", func() error { return st.SupersedePersonDateContext(ctx, personID, date.Envelope.ID, &tooEarly) }}, + {"category", func() error { return st.SupersedePersonCategoryContext(ctx, personID, category.Envelope.ID, &tooEarly) }}, + {"media", func() error { return st.SupersedePersonMediaContext(ctx, personID, media.Envelope.ID, &tooEarly) }}, + } + for _, closer := range closers { + require.ErrorIs(closer.close(), store.ErrProfileValueCloseBeforeActive, closer.name) + } + + profile, err := st.GetPersonProfileContext(ctx, personID) + require.NoError(err) + assert.Len(profile.Names, 1) + assert.Len(profile.ContactPoints, 1) + assert.Len(profile.Addresses, 1) + assert.Len(profile.Dates, 1) + assert.Len(profile.Categories, 1) + assert.Len(profile.Media, 1) +} + +func TestSupersedeObservationRejectsCloseBeforeStoredActiveFrom(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := context.Background() + participantID, err := st.EnsureParticipantByIdentifier("example", "participant-1", "Test User") + require.NoError(err) + activeFrom := time.Date(2025, 6, 2, 12, 0, 0, 0, time.UTC) + tooEarly := activeFrom.Add(-time.Second) + result, err := st.RecordContactObservationContext(ctx, participantID, store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressEmail, OriginalValue: "user@example.com", + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation, ActiveFrom: &activeFrom}, + }) + require.NoError(err) + + err = st.SupersedeParticipantObservationContext( + ctx, participantID, result.Observation.Envelope.ID, &tooEarly, + ) + require.ErrorIs(err, store.ErrProfileValueCloseBeforeActive) + observations, err := st.ListParticipantObservationsContext(ctx, participantID, true) + require.NoError(err) + assert.Len(observations, 1) +} + +func TestSupersedeDefaultsFutureDatedObservationCloseToActiveFrom(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := t.Context() + participantID, err := st.EnsureParticipantByIdentifier("example", "future-observation", "Future User") + require.NoError(err) + activeFrom := time.Date(2099, 6, 2, 12, 0, 0, 0, time.UTC) + result, err := st.RecordContactObservationContext(ctx, participantID, store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressEmail, OriginalValue: "future-observation@example.org", + Envelope: store.ValueEnvelopeInput{ + Source: store.ProvenanceArchiveObservation, ActiveFrom: &activeFrom, + }, + }) + require.NoError(err) + + require.NoError(st.SupersedeParticipantObservationContext( + ctx, participantID, result.Observation.Envelope.ID, nil, + )) + observations, err := st.ListParticipantObservationsContext(ctx, participantID, false) + require.NoError(err) + require.Len(observations, 1) + require.NotNil(observations[0].Envelope.ActiveUntil) + assert.True(activeFrom.Equal(*observations[0].Envelope.ActiveUntil)) +} + +func TestSupersedePreservesExistingWorldTimeClose(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := t.Context() + personID := newTestPerson(t, st) + activeFrom := time.Date(2025, 6, 2, 12, 0, 0, 0, time.UTC) + activeUntil := activeFrom.Add(24 * time.Hour) + name, err := st.AddPersonNameContext(ctx, personID, store.PersonNameInput{ + NameKind: store.PersonNameFormatted, Formatted: new("Historical Name"), + Envelope: store.ValueEnvelopeInput{ + Source: store.ProvenanceVCardImport, ActiveFrom: &activeFrom, ActiveUntil: &activeUntil, + }, + }) + require.NoError(err) + + require.NoError(st.SupersedePersonNameContext(ctx, personID, name.Envelope.ID, nil)) + history, err := st.GetPersonProfileHistoryContext(ctx, personID) + require.NoError(err) + require.Len(history.Names, 1) + require.NotNil(history.Names[0].Envelope.ActiveUntil) + assert.True(activeUntil.Equal(*history.Names[0].Envelope.ActiveUntil)) + assert.NotNil(history.Names[0].Envelope.SupersededAt) +} + +func TestSupersedeObservationPreservesExistingWorldTimeClose(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := t.Context() + participantID, err := st.EnsureParticipantByIdentifier("example", "participant-history", "Test User") + require.NoError(err) + activeFrom := time.Date(2025, 6, 2, 12, 0, 0, 0, time.UTC) + activeUntil := activeFrom.Add(24 * time.Hour) + result, err := st.RecordContactObservationContext(ctx, participantID, store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressEmail, OriginalValue: "history@example.com", + Envelope: store.ValueEnvelopeInput{ + Source: store.ProvenanceArchiveObservation, ActiveFrom: &activeFrom, ActiveUntil: &activeUntil, + }, + }) + require.NoError(err) + + require.NoError(st.SupersedeParticipantObservationContext( + ctx, participantID, result.Observation.Envelope.ID, nil, + )) + observations, err := st.ListParticipantObservationsContext(ctx, participantID, false) + require.NoError(err) + require.Len(observations, 1) + require.NotNil(observations[0].Envelope.ActiveUntil) + assert.True(activeUntil.Equal(*observations[0].Envelope.ActiveUntil)) + assert.NotNil(observations[0].Envelope.SupersededAt) +} diff --git a/internal/store/profile_values.go b/internal/store/profile_values.go new file mode 100644 index 000000000..fe66d9da1 --- /dev/null +++ b/internal/store/profile_values.go @@ -0,0 +1,246 @@ +package store + +import ( + "database/sql" + "errors" + "strings" + "time" +) + +var ( + ErrInvalidProfilePref = errors.New("profile value pref must be between 1 and 100") + ErrInvalidProfileOrdinal = errors.New("profile value ordinal must not be negative") + ErrProfileValueNotFound = errors.New("profile value not found") + ErrProfileValueCloseBeforeActive = errors.New("profile value close time precedes active_from") +) + +// VCardIdentity identifies one property inside one vCard resource. +type VCardIdentity struct { + Property string `json:"property,omitempty"` + Group *string `json:"group,omitempty"` + PropID *string `json:"prop_id,omitempty"` + PID []string `json:"pid,omitempty"` + AltID *string `json:"altid,omitempty"` +} + +// IsZero reports whether no vCard property identity was captured. +func (v VCardIdentity) IsZero() bool { + return v.Property == "" && v.Group == nil && v.PropID == nil && + len(v.PID) == 0 && v.AltID == nil +} + +// ValueEnvelope carries ordering, provenance, vCard identity, and history. +type ValueEnvelope struct { + ID int64 `json:"id"` + Pref *int `json:"pref,omitempty"` + Ordinal int `json:"ordinal"` + TypeLabel *string `json:"type_label,omitempty"` + TypeTokens []string `json:"type_tokens,omitempty"` + VCard VCardIdentity `json:"vcard"` + Source Provenance `json:"source"` + SourceRef *string `json:"source_ref,omitempty"` + Confidence *float64 `json:"confidence,omitempty"` + ActiveFrom *time.Time `json:"active_from,omitempty"` + ActiveUntil *time.Time `json:"active_until,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + SupersededAt *time.Time `json:"superseded_at,omitempty"` +} + +// ValueEnvelopeInput is the writable part of ValueEnvelope. Ordinal is a +// pointer because zero is a valid explicit position; nil requests automatic +// append ordering. +type ValueEnvelopeInput struct { + Pref *int `json:"pref,omitempty"` + Ordinal *int `json:"ordinal,omitempty" minimum:"0"` + TypeLabel *string `json:"type_label,omitempty"` + TypeTokens []string `json:"type_tokens,omitempty"` + VCard VCardIdentity `json:"vcard,omitzero"` + Source Provenance `json:"source"` + SourceRef *string `json:"source_ref,omitempty"` + Confidence *float64 `json:"confidence,omitempty"` + ActiveFrom *time.Time `json:"active_from,omitempty"` + ActiveUntil *time.Time `json:"active_until,omitempty"` +} + +// Validate checks writable envelope invariants before a value is inserted. +func (e ValueEnvelopeInput) Validate() error { + if e.Ordinal != nil && *e.Ordinal < 0 { + return ErrInvalidProfileOrdinal + } + return e.valueEnvelope(0).Validate() +} + +func (e ValueEnvelopeInput) valueEnvelope(ordinal int) ValueEnvelope { + if e.Ordinal != nil { + ordinal = *e.Ordinal + } + return ValueEnvelope{ + Pref: e.Pref, Ordinal: ordinal, TypeLabel: e.TypeLabel, + TypeTokens: e.TypeTokens, VCard: e.VCard, Source: e.Source, + SourceRef: e.SourceRef, Confidence: e.Confidence, + ActiveFrom: e.ActiveFrom, ActiveUntil: e.ActiveUntil, + } +} + +// IsCurrent reports whether both world time and transaction time remain open. +func (e ValueEnvelope) IsCurrent() bool { + return e.ActiveUntil == nil && e.SupersededAt == nil +} + +// Validate checks the shared profile-value invariants. +func (e ValueEnvelope) Validate() error { + if !e.Source.Valid() { + return ErrInvalidProvenance + } + if e.Pref != nil && (*e.Pref < 1 || *e.Pref > 100) { + return ErrInvalidProfilePref + } + if e.Confidence != nil && e.Source.IsDeclared() { + return ErrConfidenceScope + } + if e.Confidence != nil && (*e.Confidence < 0 || *e.Confidence > 1) { + return ErrConfidenceScope + } + if e.ActiveFrom != nil && e.ActiveUntil != nil && e.ActiveUntil.Before(*e.ActiveFrom) { + return ErrProfileValueCloseBeforeActive + } + return nil +} + +const profileEnvelopeWriteColumns = `pref, ordinal, type_label, type_tokens, ` + + `vcard_property, vcard_group, vcard_prop_id, vcard_pid, vcard_altid, ` + + `source, source_ref, confidence, active_from, active_until` + +const profileEnvelopeReadColumns = profileEnvelopeWriteColumns + + `, created_at, updated_at, superseded_at` + +func profileEnvelopeArgs(env ValueEnvelope) []any { + return []any{ + intValue(env.Pref), + env.Ordinal, + stringValue(env.TypeLabel), + joinTypeTokens(env.TypeTokens), + env.VCard.Property, + stringValue(env.VCard.Group), + stringValue(env.VCard.PropID), + joinTypeTokens(env.VCard.PID), + stringValue(env.VCard.AltID), + string(env.Source), + stringValue(env.SourceRef), + floatValue(env.Confidence), + timeValue(env.ActiveFrom), + timeValue(env.ActiveUntil), + } +} + +func joinTypeTokens(tokens []string) *string { + if len(tokens) == 0 { + return nil + } + joined := strings.Join(tokens, ",") + return &joined +} + +func splitTypeTokens(raw *string) []string { + if raw == nil || *raw == "" { + return []string{} + } + return strings.Split(*raw, ",") +} + +func stringValue(value *string) any { + if value == nil { + return nil + } + return *value +} + +func floatValue(value *float64) any { + if value == nil { + return nil + } + return *value +} + +func timeValue(value *time.Time) any { + if value == nil { + return nil + } + return *value +} + +func nullStringPtr(value sql.NullString) *string { + if !value.Valid { + return nil + } + return &value.String +} + +func nullFloatPtr(value sql.NullFloat64) *float64 { + if !value.Valid { + return nil + } + return &value.Float64 +} + +func nullTimePtr(value sql.NullTime) *time.Time { + if !value.Valid { + return nil + } + return &value.Time +} + +// profileEnvelopeScanValues lets profile tables append the shared envelope +// scan destinations after their table-specific columns without duplicating +// the null-conversion contract. +type profileEnvelopeScanValues struct { + pref, ordinal sql.NullInt64 + typeLabel, typeTokens sql.NullString + property, group, propID, pid, altID sql.NullString + source, sourceRef sql.NullString + confidence sql.NullFloat64 + activeFrom, activeUntil sql.NullTime + createdAt, updatedAt, supersededAt sql.NullTime +} + +func (v *profileEnvelopeScanValues) destinations() []any { + return []any{ + &v.pref, &v.ordinal, &v.typeLabel, &v.typeTokens, + &v.property, &v.group, &v.propID, &v.pid, &v.altID, + &v.source, &v.sourceRef, &v.confidence, &v.activeFrom, &v.activeUntil, + &v.createdAt, &v.updatedAt, &v.supersededAt, + } +} + +func (v *profileEnvelopeScanValues) apply(env *ValueEnvelope) error { + env.Pref = nullIntPtr(v.pref) + if v.ordinal.Valid { + env.Ordinal = int(v.ordinal.Int64) + } + env.TypeLabel = nullStringPtr(v.typeLabel) + env.TypeTokens = splitTypeTokens(nullStringPtr(v.typeTokens)) + env.VCard = VCardIdentity{ + Property: v.property.String, + Group: nullStringPtr(v.group), + PropID: nullStringPtr(v.propID), + PID: splitTypeTokens(nullStringPtr(v.pid)), + AltID: nullStringPtr(v.altID), + } + env.Source = Provenance(v.source.String) + env.SourceRef = nullStringPtr(v.sourceRef) + env.Confidence = nullFloatPtr(v.confidence) + env.ActiveFrom = nullTimePtr(v.activeFrom) + env.ActiveUntil = nullTimePtr(v.activeUntil) + var err error + env.CreatedAt, err = requireNullTime(v.createdAt, "created_at") + if err != nil { + return err + } + env.UpdatedAt, err = requireNullTime(v.updatedAt, "updated_at") + if err != nil { + return err + } + env.SupersededAt = nullTimePtr(v.supersededAt) + return nil +} diff --git a/internal/store/profile_values_test.go b/internal/store/profile_values_test.go new file mode 100644 index 000000000..9cca72dc3 --- /dev/null +++ b/internal/store/profile_values_test.go @@ -0,0 +1,80 @@ +package store + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValueEnvelopeValidate(t *testing.T) { + require := require.New(t) + + confidence := 0.4 + tests := []struct { + name string + env ValueEnvelope + wantErr error + }{ + { + name: "declared user value", + env: ValueEnvelope{Source: ProvenanceUser, Pref: new(1)}, + }, + { + name: "unknown source", + env: ValueEnvelope{Source: "beeper"}, + wantErr: ErrInvalidProvenance, + }, + { + name: "pref below range", + env: ValueEnvelope{Source: ProvenanceUser, Pref: new(0)}, + wantErr: ErrInvalidProfilePref, + }, + { + name: "pref above range", + env: ValueEnvelope{Source: ProvenanceUser, Pref: new(101)}, + wantErr: ErrInvalidProfilePref, + }, + { + name: "confidence on declared value", + env: ValueEnvelope{Source: ProvenanceUser, Confidence: &confidence}, + wantErr: ErrConfidenceScope, + }, + { + name: "confidence on extracted value", + env: ValueEnvelope{Source: ProvenanceExtraction, Confidence: &confidence}, + }, + } + for _, test := range tests { + err := test.env.Validate() + if test.wantErr == nil { + require.NoError(err, test.name) + continue + } + require.ErrorIs(err, test.wantErr, test.name) + } +} + +func TestValueEnvelopeIsCurrentUsesBothTimeAxes(t *testing.T) { + assert := assert.New(t) + + closed := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + assert.True(ValueEnvelope{}.IsCurrent()) + assert.False(ValueEnvelope{ActiveUntil: &closed}.IsCurrent()) + assert.False(ValueEnvelope{SupersededAt: &closed}.IsCurrent()) + assert.False(ValueEnvelope{ActiveUntil: &closed, SupersededAt: &closed}.IsCurrent()) +} + +func TestTypeTokensRoundTripPreservesOrderAndSpelling(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + + tokens := []string{"HOME", "voice", "pref"} + stored := joinTypeTokens(tokens) + require.NotNil(stored) + assert.Equal("HOME,voice,pref", *stored) + assert.Equal(tokens, splitTypeTokens(stored)) + assert.Nil(joinTypeTokens(nil)) + assert.Empty(splitTypeTokens(nil)) +} diff --git a/internal/store/schema.sql b/internal/store/schema.sql index 2d207eb20..e7f306cf4 100644 --- a/internal/store/schema.sql +++ b/internal/store/schema.sql @@ -6,6 +6,36 @@ CREATE TABLE IF NOT EXISTS archive_metadata ( value TEXT NOT NULL ); +-- Open catalog of communication services. Seeded slugs are presentation and +-- normalization metadata, NOT a database enum and not a compatibility +-- ceiling: an unknown bridge type or a custom service is registered as a new +-- row, never by a schema migration. Slugs are immutable machine identities; +-- display labels remain mutable and are never overwritten by re-seeding. +CREATE TABLE IF NOT EXISTS communication_services ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + display_label TEXT NOT NULL, + scope_policy TEXT NOT NULL DEFAULT 'none', + default_scope_kind TEXT, + normalization TEXT NOT NULL DEFAULT 'none', + normalization_version INTEGER NOT NULL DEFAULT 1, + uri_scheme TEXT, + profile_url_template TEXT, + is_system BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Aliases resolve to one canonical service without changing captured source +-- values. A primary key makes alias uniqueness a database constraint. +CREATE TABLE IF NOT EXISTS communication_service_aliases ( + alias TEXT PRIMARY KEY, + service_id INTEGER NOT NULL REFERENCES communication_services(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_communication_service_aliases_service + ON communication_service_aliases(service_id); + -- ============================================================================ -- SOURCES & IDENTITY -- ============================================================================ @@ -58,9 +88,12 @@ CREATE TABLE IF NOT EXISTS participant_identifiers ( is_primary BOOLEAN DEFAULT FALSE, + service_id INTEGER REFERENCES communication_services(id) ON DELETE SET NULL, + scope_kind TEXT, + scope_value TEXT, + UNIQUE(identifier_type, identifier_value) ); - -- Durable, user-curated people. A person's vCard UID is generated once and -- never depends on mutable participant identifiers or link-graph topology. -- UID lifecycle contract: UIDs are random and never reused. Deleting a @@ -776,6 +809,412 @@ CREATE INDEX IF NOT EXISTS idx_person_attribute_values_record_ref ON person_attribute_values(value_record_type, value_record_id) WHERE value_record_id IS NOT NULL; +-- ============================================================================ +-- PEOPLE PROFILE PRIMITIVES +-- ============================================================================ + +-- Structured, ordered person-name forms with the shared provenance and +-- two-axis history envelope. +CREATE TABLE IF NOT EXISTS person_names ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + person_id INTEGER NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + name_kind TEXT NOT NULL, + formatted TEXT, + family_name TEXT, + given_name TEXT, + additional_names TEXT, + honorific_prefixes TEXT, + honorific_suffixes TEXT, + secondary_surname TEXT, + generation TEXT, + language TEXT, + script TEXT, + phonetic_system TEXT, + phonetic_script TEXT, + sort_as TEXT, + is_derived BOOLEAN NOT NULL DEFAULT FALSE, + original_value TEXT NOT NULL, + pref INTEGER CHECK (pref IS NULL OR pref BETWEEN 1 AND 100), + ordinal INTEGER NOT NULL DEFAULT 0, + type_label TEXT, + type_tokens TEXT, + vcard_property TEXT, + vcard_group TEXT, + vcard_prop_id TEXT, + vcard_pid TEXT, + vcard_altid TEXT, + source TEXT NOT NULL + CHECK (source IN ('user', 'carddav_import', 'vcard_import', + 'archive_observation', 'extraction', 'enrichment', 'system')), + source_ref TEXT, + confidence REAL + CHECK (confidence IS NULL + OR (confidence >= 0 AND confidence <= 1 + AND source NOT IN ('user', 'carddav_import', 'vcard_import'))), + active_from DATETIME, + active_until DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + superseded_at DATETIME +); +CREATE INDEX IF NOT EXISTS idx_person_names_current + ON person_names(person_id, name_kind, ordinal) + WHERE active_until IS NULL AND superseded_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_person_names_person + ON person_names(person_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_person_names_property_identity + ON person_names(person_id, source, source_ref, vcard_property, vcard_prop_id) + WHERE source_ref IS NOT NULL AND vcard_prop_id IS NOT NULL + AND superseded_at IS NULL; + +CREATE TABLE IF NOT EXISTS person_contact_points ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + person_id INTEGER NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + address_kind TEXT NOT NULL, + service_id INTEGER REFERENCES communication_services(id) ON DELETE RESTRICT, + scope_kind TEXT, + scope_value TEXT, + original_value TEXT NOT NULL, + normalized_value TEXT NOT NULL, + normalization TEXT NOT NULL DEFAULT 'none', + normalization_version INTEGER NOT NULL DEFAULT 1, + uri TEXT, + pref INTEGER CHECK (pref IS NULL OR pref BETWEEN 1 AND 100), + ordinal INTEGER NOT NULL DEFAULT 0, + type_label TEXT, + type_tokens TEXT, + vcard_property TEXT, + vcard_group TEXT, + vcard_prop_id TEXT, + vcard_pid TEXT, + vcard_altid TEXT, + source TEXT NOT NULL + CHECK (source IN ('user', 'carddav_import', 'vcard_import', + 'archive_observation', 'extraction', 'enrichment', 'system')), + source_ref TEXT, + confidence REAL + CHECK (confidence IS NULL + OR (confidence >= 0 AND confidence <= 1 + AND source NOT IN ('user', 'carddav_import', 'vcard_import'))), + active_from DATETIME, + active_until DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + superseded_at DATETIME +); +CREATE INDEX IF NOT EXISTS idx_person_contact_points_current_lookup + ON person_contact_points(address_kind, service_id, scope_kind, scope_value, normalized_value) + WHERE active_until IS NULL AND superseded_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_person_contact_points_person_current + ON person_contact_points(person_id, address_kind, pref, ordinal) + WHERE active_until IS NULL AND superseded_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_person_contact_points_person + ON person_contact_points(person_id); +CREATE INDEX IF NOT EXISTS idx_person_contact_points_service + ON person_contact_points(service_id) WHERE service_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_person_contact_points_property_identity + ON person_contact_points(person_id, source, source_ref, vcard_property, vcard_prop_id) + WHERE source_ref IS NOT NULL AND vcard_prop_id IS NOT NULL + AND superseded_at IS NULL; + +CREATE TABLE IF NOT EXISTS person_addresses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + person_id INTEGER NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + address_kind TEXT NOT NULL DEFAULT 'postal', + post_office_box TEXT, + extended_address TEXT, + street_address TEXT, + locality TEXT, + region TEXT, + postal_code TEXT, + country_name TEXT, + extended_components TEXT, + free_text TEXT, + label TEXT, + geo_uri TEXT, + timezone TEXT, + country_code TEXT, + place_uri TEXT, + original_value TEXT NOT NULL, + pref INTEGER CHECK (pref IS NULL OR pref BETWEEN 1 AND 100), + ordinal INTEGER NOT NULL DEFAULT 0, + type_label TEXT, + type_tokens TEXT, + vcard_property TEXT, + vcard_group TEXT, + vcard_prop_id TEXT, + vcard_pid TEXT, + vcard_altid TEXT, + source TEXT NOT NULL CHECK (source IN ( + 'user', 'carddav_import', 'vcard_import', 'archive_observation', + 'extraction', 'enrichment', 'system' + )), + source_ref TEXT, + confidence REAL CHECK (confidence IS NULL OR ( + confidence >= 0 AND confidence <= 1 + AND source NOT IN ('user', 'carddav_import', 'vcard_import') + )), + active_from DATETIME, + active_until DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + superseded_at DATETIME +); +CREATE INDEX IF NOT EXISTS idx_person_addresses_current + ON person_addresses(person_id, address_kind, pref, ordinal) + WHERE active_until IS NULL AND superseded_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_person_addresses_person ON person_addresses(person_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_person_addresses_property_identity + ON person_addresses(person_id, source, source_ref, vcard_property, vcard_prop_id) + WHERE source_ref IS NOT NULL AND vcard_prop_id IS NOT NULL + AND superseded_at IS NULL; + +CREATE TABLE IF NOT EXISTS person_dates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + person_id INTEGER NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + date_kind TEXT NOT NULL, + label TEXT, + date_year INTEGER CHECK (date_year BETWEEN 1 AND 9999), + date_month INTEGER CHECK (date_month BETWEEN 1 AND 12), + date_day INTEGER CHECK (date_day BETWEEN 1 AND 31), + date_text TEXT, + calendar_scale TEXT, + original_value TEXT NOT NULL, + pref INTEGER CHECK (pref IS NULL OR pref BETWEEN 1 AND 100), + ordinal INTEGER NOT NULL DEFAULT 0, + type_label TEXT, + type_tokens TEXT, + vcard_property TEXT, + vcard_group TEXT, + vcard_prop_id TEXT, + vcard_pid TEXT, + vcard_altid TEXT, + source TEXT NOT NULL CHECK (source IN ( + 'user', 'carddav_import', 'vcard_import', 'archive_observation', + 'extraction', 'enrichment', 'system' + )), + source_ref TEXT, + confidence REAL CHECK (confidence IS NULL OR ( + confidence >= 0 AND confidence <= 1 + AND source NOT IN ('user', 'carddav_import', 'vcard_import') + )), + active_from DATETIME, + active_until DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + superseded_at DATETIME, + CHECK (date_day IS NULL OR date_month IS NOT NULL OR date_year IS NULL) +); +CREATE INDEX IF NOT EXISTS idx_person_dates_current + ON person_dates(person_id, date_kind, ordinal) + WHERE active_until IS NULL AND superseded_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_person_dates_month_day + ON person_dates(date_month, date_day) + WHERE active_until IS NULL AND superseded_at IS NULL AND date_month IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_person_dates_person ON person_dates(person_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_person_dates_property_identity + ON person_dates(person_id, source, source_ref, vcard_property, vcard_prop_id) + WHERE source_ref IS NOT NULL AND vcard_prop_id IS NOT NULL + AND superseded_at IS NULL; + +CREATE TABLE IF NOT EXISTS person_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + person_id INTEGER NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + original_value TEXT NOT NULL, + normalized_value TEXT NOT NULL, + pref INTEGER CHECK (pref IS NULL OR pref BETWEEN 1 AND 100), + ordinal INTEGER NOT NULL DEFAULT 0, + type_label TEXT, + type_tokens TEXT, + vcard_property TEXT, + vcard_group TEXT, + vcard_prop_id TEXT, + vcard_pid TEXT, + vcard_altid TEXT, + source TEXT NOT NULL CHECK (source IN ( + 'user', 'carddav_import', 'vcard_import', 'archive_observation', + 'extraction', 'enrichment', 'system' + )), + source_ref TEXT, + confidence REAL CHECK (confidence IS NULL OR ( + confidence >= 0 AND confidence <= 1 + AND source NOT IN ('user', 'carddav_import', 'vcard_import') + )), + active_from DATETIME, + active_until DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + superseded_at DATETIME +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_person_categories_current_value + ON person_categories(person_id, normalized_value) + WHERE active_until IS NULL AND superseded_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_person_categories_value + ON person_categories(normalized_value) + WHERE active_until IS NULL AND superseded_at IS NULL; + +-- Person PHOTO, LOGO, SOUND, and KEY payloads are inline because the packed +-- attachment CAS has no general write API and its liveness/GC authority is +-- the attachments table. Hash and size metadata keep later CAS migration +-- possible without changing row identity. +CREATE TABLE IF NOT EXISTS person_media ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + person_id INTEGER NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + media_kind TEXT NOT NULL, + media_type TEXT, + uri TEXT, + data BLOB, + byte_size BIGINT, + content_hash TEXT, + original_value TEXT NOT NULL, + pref INTEGER CHECK (pref IS NULL OR pref BETWEEN 1 AND 100), + ordinal INTEGER NOT NULL DEFAULT 0, + type_label TEXT, + type_tokens TEXT, + vcard_property TEXT, + vcard_group TEXT, + vcard_prop_id TEXT, + vcard_pid TEXT, + vcard_altid TEXT, + source TEXT NOT NULL CHECK (source IN ( + 'user', 'carddav_import', 'vcard_import', 'archive_observation', + 'extraction', 'enrichment', 'system' + )), + source_ref TEXT, + confidence REAL CHECK (confidence IS NULL OR ( + confidence >= 0 AND confidence <= 1 + AND source NOT IN ('user', 'carddav_import', 'vcard_import') + )), + active_from DATETIME, + active_until DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + superseded_at DATETIME +); +CREATE INDEX IF NOT EXISTS idx_person_media_current + ON person_media(person_id, media_kind, pref, ordinal) + WHERE active_until IS NULL AND superseded_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_person_media_person ON person_media(person_id); +CREATE INDEX IF NOT EXISTS idx_person_media_content_hash + ON person_media(content_hash) WHERE content_hash IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_person_media_property_identity + ON person_media(person_id, source, source_ref, vcard_property, vcard_prop_id) + WHERE source_ref IS NOT NULL AND vcard_prop_id IS NOT NULL + AND superseded_at IS NULL; + +CREATE TABLE IF NOT EXISTS participant_contact_observations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE, + source_id INTEGER REFERENCES sources(id) ON DELETE CASCADE, + address_kind TEXT NOT NULL, + service_id INTEGER REFERENCES communication_services(id) ON DELETE SET NULL, + scope_kind TEXT, + scope_value TEXT, + provider_user_id TEXT, + original_value TEXT NOT NULL, + normalized_value TEXT NOT NULL, + normalization TEXT NOT NULL DEFAULT 'none', + normalization_version INTEGER NOT NULL DEFAULT 1, + observed_at DATETIME, + pref INTEGER CHECK (pref IS NULL OR pref BETWEEN 1 AND 100), + ordinal INTEGER NOT NULL DEFAULT 0, + type_label TEXT, + type_tokens TEXT, + vcard_property TEXT, + vcard_group TEXT, + vcard_prop_id TEXT, + vcard_pid TEXT, + vcard_altid TEXT, + source TEXT NOT NULL CHECK (source IN ( + 'user', 'carddav_import', 'vcard_import', 'archive_observation', + 'extraction', 'enrichment', 'system' + )), + source_ref TEXT, + confidence REAL CHECK (confidence IS NULL OR ( + confidence >= 0 AND confidence <= 1 + AND source NOT IN ('user', 'carddav_import', 'vcard_import') + )), + active_from DATETIME, + active_until DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + superseded_at DATETIME +); +CREATE INDEX IF NOT EXISTS idx_participant_observations_current_lookup + ON participant_contact_observations( + address_kind, service_id, scope_kind, scope_value, normalized_value + ) WHERE active_until IS NULL AND superseded_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_participant_observations_participant + ON participant_contact_observations(participant_id); +CREATE INDEX IF NOT EXISTS idx_participant_observations_source + ON participant_contact_observations(source_id) WHERE source_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_participant_observations_provider_user + ON participant_contact_observations(provider_user_id) WHERE provider_user_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_participant_observations_identity + ON participant_contact_observations( + participant_id, source_id, address_kind, service_id, scope_kind, scope_value, + normalized_value + ) WHERE active_until IS NULL AND superseded_at IS NULL; + +CREATE TABLE IF NOT EXISTS identity_match_candidates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + left_kind TEXT NOT NULL, + left_id INTEGER NOT NULL, + right_kind TEXT NOT NULL, + right_id INTEGER NOT NULL, + basis TEXT NOT NULL, + service_id INTEGER REFERENCES communication_services(id) ON DELETE SET NULL, + scope_kind TEXT, + scope_value TEXT, + normalized_value TEXT, + state TEXT NOT NULL DEFAULT 'candidate', + confidence REAL CHECK (confidence IS NULL OR ( + confidence >= 0 AND confidence <= 1 + AND source NOT IN ('user', 'carddav_import', 'vcard_import') + )), + source TEXT NOT NULL CHECK (source IN ( + 'user', 'carddav_import', 'vcard_import', 'archive_observation', + 'extraction', 'enrichment', 'system' + )), + source_ref TEXT, + observation_conflict_origin TEXT CHECK ( + observation_conflict_origin IN ('generated', 'promoted') + ), + pre_conflict_state TEXT CHECK ( + pre_conflict_state IN ('candidate', 'accepted', 'rejected') + ), + decided_by TEXT, + decided_at DATETIME, + notes TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_identity_match_candidates_edge + ON identity_match_candidates( + left_kind, left_id, right_kind, right_id, basis, + service_id, scope_kind, scope_value, normalized_value + ); +CREATE INDEX IF NOT EXISTS idx_identity_match_candidates_state + ON identity_match_candidates(state, id); +CREATE INDEX IF NOT EXISTS idx_identity_match_candidates_value + ON identity_match_candidates(basis, normalized_value) + WHERE normalized_value IS NOT NULL; + +CREATE TABLE IF NOT EXISTS identity_match_evidence ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + candidate_id INTEGER NOT NULL REFERENCES identity_match_candidates(id) ON DELETE CASCADE, + evidence_kind TEXT NOT NULL, + evidence_ref TEXT, + detail TEXT, + source TEXT NOT NULL CHECK (source IN ( + 'user', 'carddav_import', 'vcard_import', 'archive_observation', + 'extraction', 'enrichment', 'system' + )), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_identity_match_evidence_candidate + ON identity_match_evidence(candidate_id, id); + -- ============================================================================ -- APPLIED MIGRATIONS -- ============================================================================ diff --git a/internal/store/schema_pg.sql b/internal/store/schema_pg.sql index 87740c0b2..e220b5e81 100644 --- a/internal/store/schema_pg.sql +++ b/internal/store/schema_pg.sql @@ -6,6 +6,36 @@ CREATE TABLE IF NOT EXISTS archive_metadata ( value TEXT NOT NULL ); +-- Open catalog of communication services. Seeded slugs are presentation and +-- normalization metadata, NOT a database enum and not a compatibility +-- ceiling: an unknown bridge type or a custom service is registered as a new +-- row, never by a schema migration. Slugs are immutable machine identities; +-- display labels remain mutable and are never overwritten by re-seeding. +CREATE TABLE IF NOT EXISTS communication_services ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + display_label TEXT NOT NULL, + scope_policy TEXT NOT NULL DEFAULT 'none', + default_scope_kind TEXT, + normalization TEXT NOT NULL DEFAULT 'none', + normalization_version INTEGER NOT NULL DEFAULT 1, + uri_scheme TEXT, + profile_url_template TEXT, + is_system BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Aliases resolve to one canonical service without changing captured source +-- values. A primary key makes alias uniqueness a database constraint. +CREATE TABLE IF NOT EXISTS communication_service_aliases ( + alias TEXT PRIMARY KEY, + service_id BIGINT NOT NULL REFERENCES communication_services(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_communication_service_aliases_service + ON communication_service_aliases(service_id); + -- ============================================================================ -- SOURCES & IDENTITY -- ============================================================================ @@ -52,9 +82,12 @@ CREATE TABLE IF NOT EXISTS participant_identifiers ( is_primary BOOLEAN DEFAULT FALSE, + service_id BIGINT REFERENCES communication_services(id) ON DELETE SET NULL, + scope_kind TEXT, + scope_value TEXT, + UNIQUE(identifier_type, identifier_value) ); - -- Durable, user-curated people. A person's vCard UID is generated once and -- never depends on mutable participant identifiers or link-graph topology. -- UID lifecycle contract: UIDs are random and never reused. Deleting a @@ -556,6 +589,412 @@ CREATE INDEX IF NOT EXISTS idx_person_attribute_values_record_ref ON person_attribute_values(value_record_type, value_record_id) WHERE value_record_id IS NOT NULL; +-- ============================================================================ +-- PEOPLE PROFILE PRIMITIVES +-- ============================================================================ + +-- Structured, ordered person-name forms with the shared provenance and +-- two-axis history envelope. +CREATE TABLE IF NOT EXISTS person_names ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + person_id BIGINT NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + name_kind TEXT NOT NULL, + formatted TEXT, + family_name TEXT, + given_name TEXT, + additional_names TEXT, + honorific_prefixes TEXT, + honorific_suffixes TEXT, + secondary_surname TEXT, + generation TEXT, + language TEXT, + script TEXT, + phonetic_system TEXT, + phonetic_script TEXT, + sort_as TEXT, + is_derived BOOLEAN NOT NULL DEFAULT FALSE, + original_value TEXT NOT NULL, + pref INTEGER CHECK (pref IS NULL OR pref BETWEEN 1 AND 100), + ordinal INTEGER NOT NULL DEFAULT 0, + type_label TEXT, + type_tokens TEXT, + vcard_property TEXT, + vcard_group TEXT, + vcard_prop_id TEXT, + vcard_pid TEXT, + vcard_altid TEXT, + source TEXT NOT NULL + CHECK (source IN ('user', 'carddav_import', 'vcard_import', + 'archive_observation', 'extraction', 'enrichment', 'system')), + source_ref TEXT, + confidence DOUBLE PRECISION + CHECK (confidence IS NULL + OR (confidence >= 0 AND confidence <= 1 + AND source NOT IN ('user', 'carddav_import', 'vcard_import'))), + active_from TIMESTAMPTZ, + active_until TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + superseded_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS idx_person_names_current + ON person_names(person_id, name_kind, ordinal) + WHERE active_until IS NULL AND superseded_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_person_names_person + ON person_names(person_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_person_names_property_identity + ON person_names(person_id, source, source_ref, vcard_property, vcard_prop_id) + WHERE source_ref IS NOT NULL AND vcard_prop_id IS NOT NULL + AND superseded_at IS NULL; + +CREATE TABLE IF NOT EXISTS person_contact_points ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + person_id BIGINT NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + address_kind TEXT NOT NULL, + service_id BIGINT REFERENCES communication_services(id) ON DELETE RESTRICT, + scope_kind TEXT, + scope_value TEXT, + original_value TEXT NOT NULL, + normalized_value TEXT NOT NULL, + normalization TEXT NOT NULL DEFAULT 'none', + normalization_version INTEGER NOT NULL DEFAULT 1, + uri TEXT, + pref INTEGER CHECK (pref IS NULL OR pref BETWEEN 1 AND 100), + ordinal INTEGER NOT NULL DEFAULT 0, + type_label TEXT, + type_tokens TEXT, + vcard_property TEXT, + vcard_group TEXT, + vcard_prop_id TEXT, + vcard_pid TEXT, + vcard_altid TEXT, + source TEXT NOT NULL + CHECK (source IN ('user', 'carddav_import', 'vcard_import', + 'archive_observation', 'extraction', 'enrichment', 'system')), + source_ref TEXT, + confidence DOUBLE PRECISION + CHECK (confidence IS NULL + OR (confidence >= 0 AND confidence <= 1 + AND source NOT IN ('user', 'carddav_import', 'vcard_import'))), + active_from TIMESTAMPTZ, + active_until TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + superseded_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS idx_person_contact_points_current_lookup + ON person_contact_points(address_kind, service_id, scope_kind, scope_value, normalized_value) + WHERE active_until IS NULL AND superseded_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_person_contact_points_person_current + ON person_contact_points(person_id, address_kind, pref, ordinal) + WHERE active_until IS NULL AND superseded_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_person_contact_points_person + ON person_contact_points(person_id); +CREATE INDEX IF NOT EXISTS idx_person_contact_points_service + ON person_contact_points(service_id) WHERE service_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_person_contact_points_property_identity + ON person_contact_points(person_id, source, source_ref, vcard_property, vcard_prop_id) + WHERE source_ref IS NOT NULL AND vcard_prop_id IS NOT NULL + AND superseded_at IS NULL; + +CREATE TABLE IF NOT EXISTS person_addresses ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + person_id BIGINT NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + address_kind TEXT NOT NULL DEFAULT 'postal', + post_office_box TEXT, + extended_address TEXT, + street_address TEXT, + locality TEXT, + region TEXT, + postal_code TEXT, + country_name TEXT, + extended_components TEXT, + free_text TEXT, + label TEXT, + geo_uri TEXT, + timezone TEXT, + country_code TEXT, + place_uri TEXT, + original_value TEXT NOT NULL, + pref INTEGER CHECK (pref IS NULL OR pref BETWEEN 1 AND 100), + ordinal INTEGER NOT NULL DEFAULT 0, + type_label TEXT, + type_tokens TEXT, + vcard_property TEXT, + vcard_group TEXT, + vcard_prop_id TEXT, + vcard_pid TEXT, + vcard_altid TEXT, + source TEXT NOT NULL CHECK (source IN ( + 'user', 'carddav_import', 'vcard_import', 'archive_observation', + 'extraction', 'enrichment', 'system' + )), + source_ref TEXT, + confidence DOUBLE PRECISION CHECK (confidence IS NULL OR ( + confidence >= 0 AND confidence <= 1 + AND source NOT IN ('user', 'carddav_import', 'vcard_import') + )), + active_from TIMESTAMPTZ, + active_until TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + superseded_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS idx_person_addresses_current + ON person_addresses(person_id, address_kind, pref, ordinal) + WHERE active_until IS NULL AND superseded_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_person_addresses_person ON person_addresses(person_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_person_addresses_property_identity + ON person_addresses(person_id, source, source_ref, vcard_property, vcard_prop_id) + WHERE source_ref IS NOT NULL AND vcard_prop_id IS NOT NULL + AND superseded_at IS NULL; + +CREATE TABLE IF NOT EXISTS person_dates ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + person_id BIGINT NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + date_kind TEXT NOT NULL, + label TEXT, + date_year INTEGER CHECK (date_year BETWEEN 1 AND 9999), + date_month INTEGER CHECK (date_month BETWEEN 1 AND 12), + date_day INTEGER CHECK (date_day BETWEEN 1 AND 31), + date_text TEXT, + calendar_scale TEXT, + original_value TEXT NOT NULL, + pref INTEGER CHECK (pref IS NULL OR pref BETWEEN 1 AND 100), + ordinal INTEGER NOT NULL DEFAULT 0, + type_label TEXT, + type_tokens TEXT, + vcard_property TEXT, + vcard_group TEXT, + vcard_prop_id TEXT, + vcard_pid TEXT, + vcard_altid TEXT, + source TEXT NOT NULL CHECK (source IN ( + 'user', 'carddav_import', 'vcard_import', 'archive_observation', + 'extraction', 'enrichment', 'system' + )), + source_ref TEXT, + confidence DOUBLE PRECISION CHECK (confidence IS NULL OR ( + confidence >= 0 AND confidence <= 1 + AND source NOT IN ('user', 'carddav_import', 'vcard_import') + )), + active_from TIMESTAMPTZ, + active_until TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + superseded_at TIMESTAMPTZ, + CHECK (date_day IS NULL OR date_month IS NOT NULL OR date_year IS NULL) +); +CREATE INDEX IF NOT EXISTS idx_person_dates_current + ON person_dates(person_id, date_kind, ordinal) + WHERE active_until IS NULL AND superseded_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_person_dates_month_day + ON person_dates(date_month, date_day) + WHERE active_until IS NULL AND superseded_at IS NULL AND date_month IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_person_dates_person ON person_dates(person_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_person_dates_property_identity + ON person_dates(person_id, source, source_ref, vcard_property, vcard_prop_id) + WHERE source_ref IS NOT NULL AND vcard_prop_id IS NOT NULL + AND superseded_at IS NULL; + +CREATE TABLE IF NOT EXISTS person_categories ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + person_id BIGINT NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + original_value TEXT NOT NULL, + normalized_value TEXT NOT NULL, + pref INTEGER CHECK (pref IS NULL OR pref BETWEEN 1 AND 100), + ordinal INTEGER NOT NULL DEFAULT 0, + type_label TEXT, + type_tokens TEXT, + vcard_property TEXT, + vcard_group TEXT, + vcard_prop_id TEXT, + vcard_pid TEXT, + vcard_altid TEXT, + source TEXT NOT NULL CHECK (source IN ( + 'user', 'carddav_import', 'vcard_import', 'archive_observation', + 'extraction', 'enrichment', 'system' + )), + source_ref TEXT, + confidence DOUBLE PRECISION CHECK (confidence IS NULL OR ( + confidence >= 0 AND confidence <= 1 + AND source NOT IN ('user', 'carddav_import', 'vcard_import') + )), + active_from TIMESTAMPTZ, + active_until TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + superseded_at TIMESTAMPTZ +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_person_categories_current_value + ON person_categories(person_id, normalized_value) + WHERE active_until IS NULL AND superseded_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_person_categories_value + ON person_categories(normalized_value) + WHERE active_until IS NULL AND superseded_at IS NULL; + +-- Person PHOTO, LOGO, SOUND, and KEY payloads are inline because the packed +-- attachment CAS has no general write API and its liveness/GC authority is +-- the attachments table. Hash and size metadata keep later CAS migration +-- possible without changing row identity. +CREATE TABLE IF NOT EXISTS person_media ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + person_id BIGINT NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + media_kind TEXT NOT NULL, + media_type TEXT, + uri TEXT, + data BYTEA, + byte_size BIGINT, + content_hash TEXT, + original_value TEXT NOT NULL, + pref INTEGER CHECK (pref IS NULL OR pref BETWEEN 1 AND 100), + ordinal INTEGER NOT NULL DEFAULT 0, + type_label TEXT, + type_tokens TEXT, + vcard_property TEXT, + vcard_group TEXT, + vcard_prop_id TEXT, + vcard_pid TEXT, + vcard_altid TEXT, + source TEXT NOT NULL CHECK (source IN ( + 'user', 'carddav_import', 'vcard_import', 'archive_observation', + 'extraction', 'enrichment', 'system' + )), + source_ref TEXT, + confidence DOUBLE PRECISION CHECK (confidence IS NULL OR ( + confidence >= 0 AND confidence <= 1 + AND source NOT IN ('user', 'carddav_import', 'vcard_import') + )), + active_from TIMESTAMPTZ, + active_until TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + superseded_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS idx_person_media_current + ON person_media(person_id, media_kind, pref, ordinal) + WHERE active_until IS NULL AND superseded_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_person_media_person ON person_media(person_id); +CREATE INDEX IF NOT EXISTS idx_person_media_content_hash + ON person_media(content_hash) WHERE content_hash IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_person_media_property_identity + ON person_media(person_id, source, source_ref, vcard_property, vcard_prop_id) + WHERE source_ref IS NOT NULL AND vcard_prop_id IS NOT NULL + AND superseded_at IS NULL; + +CREATE TABLE IF NOT EXISTS participant_contact_observations ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + participant_id BIGINT NOT NULL REFERENCES participants(id) ON DELETE CASCADE, + source_id BIGINT REFERENCES sources(id) ON DELETE CASCADE, + address_kind TEXT NOT NULL, + service_id BIGINT REFERENCES communication_services(id) ON DELETE SET NULL, + scope_kind TEXT, + scope_value TEXT, + provider_user_id TEXT, + original_value TEXT NOT NULL, + normalized_value TEXT NOT NULL, + normalization TEXT NOT NULL DEFAULT 'none', + normalization_version INTEGER NOT NULL DEFAULT 1, + observed_at TIMESTAMPTZ, + pref INTEGER CHECK (pref IS NULL OR pref BETWEEN 1 AND 100), + ordinal INTEGER NOT NULL DEFAULT 0, + type_label TEXT, + type_tokens TEXT, + vcard_property TEXT, + vcard_group TEXT, + vcard_prop_id TEXT, + vcard_pid TEXT, + vcard_altid TEXT, + source TEXT NOT NULL CHECK (source IN ( + 'user', 'carddav_import', 'vcard_import', 'archive_observation', + 'extraction', 'enrichment', 'system' + )), + source_ref TEXT, + confidence DOUBLE PRECISION CHECK (confidence IS NULL OR ( + confidence >= 0 AND confidence <= 1 + AND source NOT IN ('user', 'carddav_import', 'vcard_import') + )), + active_from TIMESTAMPTZ, + active_until TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + superseded_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS idx_participant_observations_current_lookup + ON participant_contact_observations( + address_kind, service_id, scope_kind, scope_value, normalized_value + ) WHERE active_until IS NULL AND superseded_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_participant_observations_participant + ON participant_contact_observations(participant_id); +CREATE INDEX IF NOT EXISTS idx_participant_observations_source + ON participant_contact_observations(source_id) WHERE source_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_participant_observations_provider_user + ON participant_contact_observations(provider_user_id) WHERE provider_user_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_participant_observations_identity + ON participant_contact_observations( + participant_id, source_id, address_kind, service_id, scope_kind, scope_value, + normalized_value + ) WHERE active_until IS NULL AND superseded_at IS NULL; + +CREATE TABLE IF NOT EXISTS identity_match_candidates ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + left_kind TEXT NOT NULL, + left_id BIGINT NOT NULL, + right_kind TEXT NOT NULL, + right_id BIGINT NOT NULL, + basis TEXT NOT NULL, + service_id BIGINT REFERENCES communication_services(id) ON DELETE SET NULL, + scope_kind TEXT, + scope_value TEXT, + normalized_value TEXT, + state TEXT NOT NULL DEFAULT 'candidate', + source TEXT NOT NULL CHECK (source IN ( + 'user', 'carddav_import', 'vcard_import', 'archive_observation', + 'extraction', 'enrichment', 'system' + )), + confidence DOUBLE PRECISION CHECK (confidence IS NULL OR ( + confidence >= 0 AND confidence <= 1 + AND source NOT IN ('user', 'carddav_import', 'vcard_import') + )), + source_ref TEXT, + observation_conflict_origin TEXT CHECK ( + observation_conflict_origin IN ('generated', 'promoted') + ), + pre_conflict_state TEXT CHECK ( + pre_conflict_state IN ('candidate', 'accepted', 'rejected') + ), + decided_by TEXT, + decided_at TIMESTAMPTZ, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_identity_match_candidates_edge + ON identity_match_candidates( + left_kind, left_id, right_kind, right_id, basis, + service_id, scope_kind, scope_value, normalized_value + ); +CREATE INDEX IF NOT EXISTS idx_identity_match_candidates_state + ON identity_match_candidates(state, id); +CREATE INDEX IF NOT EXISTS idx_identity_match_candidates_value + ON identity_match_candidates(basis, normalized_value) + WHERE normalized_value IS NOT NULL; + +CREATE TABLE IF NOT EXISTS identity_match_evidence ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + candidate_id BIGINT NOT NULL REFERENCES identity_match_candidates(id) ON DELETE CASCADE, + evidence_kind TEXT NOT NULL, + evidence_ref TEXT, + detail TEXT, + source TEXT NOT NULL CHECK (source IN ( + 'user', 'carddav_import', 'vcard_import', 'archive_observation', + 'extraction', 'enrichment', 'system' + )), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_identity_match_evidence_candidate + ON identity_match_evidence(candidate_id, id); + -- Marks one-time data migrations that have already run. Schema DDL is -- idempotent via IF NOT EXISTS; this table is for *data* migrations -- (e.g. moving legacy config into per-account records) that must run diff --git a/internal/store/sources.go b/internal/store/sources.go index fc3d9cd3d..3f008c44e 100644 --- a/internal/store/sources.go +++ b/internal/store/sources.go @@ -194,7 +194,7 @@ func (s *Store) GetSourcesByTypeAndAccountContext( // No-op timeout reset on SQLite. func (s *Store) RemoveSource(sourceID int64) error { return s.runMaintenance(context.Background(), func(ctx context.Context, tx *loggedTx) error { - return s.removeSourceExec(tx, sourceID) + return s.removeSourceExec(ctx, tx, sourceID) }) } @@ -270,6 +270,11 @@ func (s *Store) RemoveSourceSerialized( return hadActiveSync, 0, fmt.Errorf("delete FTS rows: %w", err) } } + if err := s.deleteSourceObservationIdentityCandidatesContext( + ctx, conn, sourceID, + ); err != nil { + return hadActiveSync, 0, err + } res, err := conn.ExecContext( ctx, s.dialect.Rebind(`DELETE FROM sources WHERE id = ?`), sourceID, @@ -284,6 +289,9 @@ func (s *Store) RemoveSourceSerialized( if deletedSources == 0 { return hadActiveSync, 0, fmt.Errorf("source %d not found", sourceID) } + if err := s.deleteUnsupportedObservationIdentityConflictsContext(ctx, conn); err != nil { + return hadActiveSync, 0, err + } const deleteChunkSize = 500 for start := 0; start < len(uniquePackedHashes); start += deleteChunkSize { @@ -337,14 +345,33 @@ const packedBlobHashesUniqueToSourceSQL = ` ) ORDER BY sb.blob_hash` -// removeSourceExec performs the FTS + sources DELETE on a generic executor -// (either a *loggedTx or *sql.Conn under a manual transaction). -func (s *Store) removeSourceExec(tx *loggedTx, sourceID int64) error { +// removeSourceExec performs the identity cleanup, FTS cleanup, and source +// deletion in one maintenance transaction. +func (s *Store) removeSourceExec( + ctx context.Context, tx *loggedTx, sourceID int64, +) error { + // Identity candidate writes take this lock before validating and writing + // their polymorphic endpoints. Taking the same lock before source cleanup + // prevents a candidate from being inserted after cleanup but before the + // source cascade removes its observation endpoint. + if err := s.lockIdentityMutationTxContext(ctx, tx); err != nil { + return err + } + if err := s.lockProfileIdentityKeyTxContext( + ctx, tx, "source-contact-observation", sourceID, + ); err != nil { + return err + } if s.fts5Available { if _, err := tx.Exec(s.dialect.FTSDeleteSQL(), sourceID); err != nil { return fmt.Errorf("delete FTS rows: %w", err) } } + if err := s.deleteSourceObservationIdentityCandidatesContext( + ctx, tx, sourceID, + ); err != nil { + return err + } res, err := tx.Exec(`DELETE FROM sources WHERE id = ?`, sourceID) if err != nil { return fmt.Errorf("delete source: %w", err) @@ -356,5 +383,85 @@ func (s *Store) removeSourceExec(tx *loggedTx, sourceID int64) error { if rows == 0 { return fmt.Errorf("source %d not found", sourceID) } + if err := s.deleteUnsupportedObservationIdentityConflictsContext(ctx, tx); err != nil { + return err + } return nil } + +// deleteSourceObservationIdentityCandidatesContext removes generated +// participant conflicts and candidates with observation endpoints before the +// source cascade removes those observations. Evidence for deleted candidates +// follows its candidate foreign-key cascade. After the source deletion, the +// shared cleanup demotes promoted participant candidates whose support is gone. +func (s *Store) deleteSourceObservationIdentityCandidatesContext( + ctx context.Context, execer contextQuerier, sourceID int64, +) error { + // Automatically generated conflicts use participant endpoints so they can + // be reviewed as person-link candidates. Recompute their backing contact + // observations while the source rows still exist: remove a conflict only + // when the deleted source participates in it and no genuinely conflicting + // current observation pair remains outside that source. + if _, err := execer.ExecContext(ctx, s.dialect.Rebind(` + WITH stale_conflicts AS ( + SELECT c.id + FROM identity_match_candidates c + WHERE c.left_kind = 'participant' + AND c.right_kind = 'participant' + AND c.state = 'conflict' + AND c.observation_conflict_origin = 'generated' + AND c.normalized_value IS NOT NULL + AND c.basis IN ('email', 'phone', 'service_scope_username') + AND EXISTS ( + SELECT 1 FROM participant_contact_observations removed + WHERE removed.source_id = ? + AND removed.participant_id IN (c.left_id, c.right_id) + AND `+identityCandidateObservationMatchSQL("removed")+` + ) + AND NOT EXISTS ( + SELECT 1 FROM participant_contact_observations kept_left + WHERE kept_left.participant_id = c.left_id + AND (kept_left.source_id IS NULL OR kept_left.source_id != ?) + AND `+identityCandidateObservationMatchSQL("kept_left")+` + AND EXISTS ( + SELECT 1 FROM participant_contact_observations kept_right + WHERE kept_right.participant_id = c.right_id + AND (kept_right.source_id IS NULL OR kept_right.source_id != ?) + AND `+identityCandidateObservationMatchSQL("kept_right")+` + AND `+identityCandidateObservationPairSQL( + "kept_left", "kept_right", + )+` + ) + ) + ) + DELETE FROM identity_match_candidates + WHERE id IN (SELECT id FROM stale_conflicts)`), + sourceID, sourceID, sourceID); err != nil { + return fmt.Errorf("delete stale source observation conflicts: %w", err) + } + if _, err := execer.ExecContext(ctx, s.dialect.Rebind(` + DELETE FROM identity_match_candidates + WHERE (left_kind = 'observation' AND left_id IN ( + SELECT id FROM participant_contact_observations WHERE source_id = ? + )) OR (right_kind = 'observation' AND right_id IN ( + SELECT id FROM participant_contact_observations WHERE source_id = ? + ))`), sourceID, sourceID); err != nil { + return fmt.Errorf("delete source observation identity candidates: %w", err) + } + return nil +} + +func identityCandidateObservationMatchSQL(observationAlias string) string { + return `(c.basis = 'email' AND ` + observationAlias + `.address_kind = 'email' + OR c.basis = 'phone' AND ` + observationAlias + `.address_kind = 'phone' + OR c.basis = 'service_scope_username' AND ` + observationAlias + `.address_kind NOT IN ('email', 'phone')) + AND (` + observationAlias + `.service_id = c.service_id OR + (` + observationAlias + `.service_id IS NULL AND c.service_id IS NULL)) + AND (` + observationAlias + `.scope_kind = c.scope_kind OR + (` + observationAlias + `.scope_kind IS NULL AND c.scope_kind IS NULL)) + AND (` + observationAlias + `.scope_value = c.scope_value OR + (` + observationAlias + `.scope_value IS NULL AND c.scope_value IS NULL)) + AND ` + observationAlias + `.normalized_value = c.normalized_value + AND ` + observationAlias + `.active_until IS NULL + AND ` + observationAlias + `.superseded_at IS NULL` +} diff --git a/internal/store/sources_test.go b/internal/store/sources_test.go index 6a85de621..942bbcd4c 100644 --- a/internal/store/sources_test.go +++ b/internal/store/sources_test.go @@ -3,6 +3,7 @@ package store_test import ( "context" "database/sql" + "fmt" "io/fs" "os" "path/filepath" @@ -108,6 +109,287 @@ func TestStore_RemoveSource_NotFound(t *testing.T) { require.Error(t, err, "RemoveSource should error for nonexistent ID") } +func TestStore_RemoveSourceRemovesObservationIdentityCandidates(t *testing.T) { + for _, test := range []struct { + name string + remove func(context.Context, *store.Store, int64) error + }{ + { + name: "maintenance", + remove: func(_ context.Context, st *store.Store, sourceID int64) error { + return st.RemoveSource(sourceID) + }, + }, + { + name: "serialized", + remove: func(ctx context.Context, st *store.Store, sourceID int64) error { + _, _, err := st.RemoveSourceSerialized(ctx, sourceID) + return err + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + ctx := t.Context() + + observed, err := f.Store.EnsureParticipantByIdentifier( + "example", "observed", "Observed", + ) + require.NoError(err) + other, err := f.Store.EnsureParticipantByIdentifier( + "example", "other", "Other", + ) + require.NoError(err) + observation, err := f.Store.RecordContactObservationContext( + ctx, observed, store.ParticipantContactObservationInput{ + SourceID: &f.Source.ID, AddressKind: store.ContactAddressEmail, + OriginalValue: "observed@example.org", + Envelope: store.ValueEnvelopeInput{ + Source: store.ProvenanceArchiveObservation, + }, + }, + ) + require.NoError(err) + candidate, created, err := f.Store.UpsertIdentityMatchCandidateContext( + ctx, store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchObservation, + LeftID: observation.Observation.Envelope.ID, + RightKind: store.IdentityMatchParticipant, + RightID: other, + Basis: store.IdentityMatchEmail, + State: store.IdentityMatchStateCandidate, + Source: store.ProvenanceArchiveObservation, + }, + ) + require.NoError(err) + require.True(created) + _, err = f.Store.AddIdentityMatchEvidenceContext( + ctx, candidate.ID, store.IdentityMatchEvidenceInput{ + EvidenceKind: "display_name", + Source: store.ProvenanceArchiveObservation, + }, + ) + require.NoError(err) + + require.NoError(test.remove(ctx, f.Store, f.Source.ID)) + + for table, want := range map[string]int{ + "participant_contact_observations": 0, + "identity_match_candidates": 0, + "identity_match_evidence": 0, + } { + var got int + err := f.Store.DB().QueryRow( + "SELECT COUNT(*) FROM " + table, + ).Scan(&got) + require.NoError(err, "count %s", table) + assert.Equal(want, got, table) + } + }) + } +} + +func TestStore_RemoveSourceRecomputesGeneratedObservationConflicts(t *testing.T) { + for _, test := range []struct { + kind store.ContactAddressKind + value string + }{ + {kind: store.ContactAddressEmail, value: "shared@example.org"}, + {kind: store.ContactAddressPhone, value: "+1 202 555 0147"}, + {kind: store.ContactAddressUsername, value: "shared-user"}, + {kind: store.ContactAddressIMPP, value: "im:shared"}, + {kind: store.ContactAddressURL, value: "https://example.org/shared"}, + {kind: store.ContactAddressSocial, value: "social:shared"}, + {kind: store.ContactAddressCalendar, value: "calendar:shared"}, + {kind: store.ContactAddressContactURI, value: "contact:shared"}, + {kind: store.ContactAddressOrgDirectory, value: "directory:shared"}, + {kind: store.ContactAddressLanguage, value: "EN"}, + } { + t.Run(string(test.kind), func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + ctx := t.Context() + left := f.EnsureParticipant("left@example.com", "Left", "example.com") + right := f.EnsureParticipant("right@example.com", "Right", "example.com") + + for index, participantID := range []int64{left, right} { + result, err := f.Store.RecordContactObservationContext( + ctx, participantID, store.ParticipantContactObservationInput{ + SourceID: &f.Source.ID, AddressKind: test.kind, + ProviderUserID: new(fmt.Sprintf("provider-%d", index)), + OriginalValue: test.value, + Envelope: store.ValueEnvelopeInput{ + Source: store.ProvenanceArchiveObservation, + }, + }, + ) + require.NoError(err) + if participantID == right { + assert.True(result.Conflicting) + require.NotNil(result.CandidateID) + } + } + + candidates, err := f.Store.ListIdentityMatchCandidatesContext(ctx, nil, 10, 0) + require.NoError(err) + require.Len(candidates, 1) + assert.Equal(store.IdentityMatchParticipant, candidates[0].LeftKind) + assert.Equal(store.IdentityMatchParticipant, candidates[0].RightKind) + + require.NoError(f.Store.RemoveSource(f.Source.ID)) + candidates, err = f.Store.ListIdentityMatchCandidatesContext(ctx, nil, 10, 0) + require.NoError(err) + assert.Empty(candidates) + }) + } +} + +func TestStore_RemoveSourceKeepsGeneratedConflictWithOtherSourceSupport(t *testing.T) { + require := require.New(t) + f := storetest.New(t) + ctx := t.Context() + left := f.EnsureParticipant("left@example.com", "Left", "example.com") + right := f.EnsureParticipant("right@example.com", "Right", "example.com") + otherSource, err := f.Store.GetOrCreateSource("gmail", "other@example.org") + require.NoError(err) + + for _, sourceID := range []int64{f.Source.ID, otherSource.ID} { + for _, participantID := range []int64{left, right} { + _, err := f.Store.RecordContactObservationContext( + ctx, participantID, store.ParticipantContactObservationInput{ + SourceID: &sourceID, AddressKind: store.ContactAddressEmail, + OriginalValue: "shared@example.org", + Envelope: store.ValueEnvelopeInput{ + Source: store.ProvenanceArchiveObservation, + }, + }, + ) + require.NoError(err) + } + } + + require.NoError(f.Store.RemoveSource(f.Source.ID)) + candidates, err := f.Store.ListIdentityMatchCandidatesContext(ctx, nil, 10, 0) + require.NoError(err) + require.Len(candidates, 1, "the second source still supports both endpoints") + + require.NoError(f.Store.RemoveSource(otherSource.ID)) + candidates, err = f.Store.ListIdentityMatchCandidatesContext(ctx, nil, 10, 0) + require.NoError(err) + require.Empty(candidates) +} + +func TestStoreRemoveSourceDemotesPromotedObservationConflict(t *testing.T) { + for name, remove := range map[string]func(context.Context, *store.Store, int64) error{ + "maintenance": func(_ context.Context, st *store.Store, sourceID int64) error { + return st.RemoveSource(sourceID) + }, + "serialized": func(ctx context.Context, st *store.Store, sourceID int64) error { + _, _, err := st.RemoveSourceSerialized(ctx, sourceID) + return err + }, + } { + t.Run(name, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + ctx := t.Context() + left := f.EnsureParticipant("promoted-left@example.org", "Promoted Left", "example.org") + right := f.EnsureParticipant("promoted-right@example.org", "Promoted Right", "example.org") + normalized := "shared-promoted@example.org" + notes := "preserve source-removal review" + candidate, created, err := f.Store.UpsertIdentityMatchCandidateContext( + ctx, store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchParticipant, LeftID: left, + RightKind: store.IdentityMatchParticipant, RightID: right, + Basis: store.IdentityMatchEmail, NormalizedValue: &normalized, + State: store.IdentityMatchStateCandidate, Source: store.ProvenanceSystem, + Notes: ¬es, + }, + ) + require.NoError(err) + require.True(created) + _, err = f.Store.AddIdentityMatchEvidenceContext(ctx, candidate.ID, store.IdentityMatchEvidenceInput{ + EvidenceKind: "system_review", Detail: new("preserve source-removal evidence"), + Source: store.ProvenanceSystem, + }) + require.NoError(err) + + for index, participantID := range []int64{left, right} { + _, err := f.Store.RecordContactObservationContext( + ctx, participantID, store.ParticipantContactObservationInput{ + SourceID: &f.Source.ID, AddressKind: store.ContactAddressEmail, + ProviderUserID: new(fmt.Sprintf("promoted-provider-%d", index)), + OriginalValue: normalized, + Envelope: store.ValueEnvelopeInput{ + Source: store.ProvenanceArchiveObservation, + }, + }, + ) + require.NoError(err) + } + + require.NoError(remove(ctx, f.Store, f.Source.ID)) + candidates, err := f.Store.ListIdentityMatchCandidatesContext(ctx, nil, 10, 0) + require.NoError(err) + require.Len(candidates, 1) + assert.Equal(candidate.ID, candidates[0].ID) + assert.Equal(store.IdentityMatchStateCandidate, candidates[0].State) + assert.Equal(store.ProvenanceSystem, candidates[0].Source) + assert.Equal(¬es, candidates[0].Notes) + require.Len(candidates[0].Evidence, 1) + assert.Equal("system_review", candidates[0].Evidence[0].EvidenceKind) + }) + } +} + +func TestStore_RemoveSourceKeepsConflictsBetweenOtherParticipants(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + ctx := t.Context() + participants := []int64{ + f.EnsureParticipant("first@example.com", "First", "example.com"), + f.EnsureParticipant("second@example.com", "Second", "example.com"), + f.EnsureParticipant("third@example.com", "Third", "example.com"), + } + sources := []int64{f.Source.ID} + for _, identifier := range []string{"second-source@example.org", "third-source@example.org"} { + source, err := f.Store.GetOrCreateSource("gmail", identifier) + require.NoError(err) + sources = append(sources, source.ID) + } + + for index, participantID := range participants { + _, err := f.Store.RecordContactObservationContext( + ctx, participantID, store.ParticipantContactObservationInput{ + SourceID: &sources[index], AddressKind: store.ContactAddressEmail, + ProviderUserID: new(fmt.Sprintf("provider-%d", index)), + OriginalValue: "shared@example.org", + Envelope: store.ValueEnvelopeInput{ + Source: store.ProvenanceArchiveObservation, + }, + }, + ) + require.NoError(err) + } + candidates, err := f.Store.ListIdentityMatchCandidatesContext(ctx, nil, 10, 0) + require.NoError(err) + require.Len(candidates, 3, "three participants require a complete conflict graph") + + require.NoError(f.Store.RemoveSource(sources[0])) + candidates, err = f.Store.ListIdentityMatchCandidatesContext(ctx, nil, 10, 0) + require.NoError(err) + require.Len(candidates, 1) + assert.ElementsMatch( + []int64{participants[1], participants[2]}, + []int64{candidates[0].LeftID, candidates[0].RightID}, + ) +} + func TestStore_RemoveSource_CascadesConversations(t *testing.T) { require := require.New(t) assert := assert.New(t) diff --git a/internal/store/store.go b/internal/store/store.go index 998008621..6fe76cb4a 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -556,9 +556,28 @@ func (s *Store) withTx(fn func(tx *loggedTx) error) error { // connection acquisition and every context-aware statement in the // transaction. func (s *Store) withTxContext(ctx context.Context, fn func(tx *loggedTx) error) error { + return s.withTxOptionsContext(ctx, nil, fn) +} + +// withReadSnapshotContext gives a multi-statement aggregate read one stable, +// read-only database snapshot. PostgreSQL needs REPEATABLE READ because its +// default READ COMMITTED isolation takes a new snapshot for each statement. +// SQLite's driver maps these options to its existing transaction snapshot. +func (s *Store) withReadSnapshotContext( + ctx context.Context, fn func(tx *loggedTx) error, +) error { + return s.withTxOptionsContext(ctx, &sql.TxOptions{ + Isolation: sql.LevelRepeatableRead, + ReadOnly: true, + }, fn) +} + +func (s *Store) withTxOptionsContext( + ctx context.Context, opts *sql.TxOptions, fn func(tx *loggedTx) error, +) error { start := time.Now() slog.Debug("sql tx begin") - tx, err := s.db.BeginTx(ctx, nil) + tx, err := s.db.BeginTx(ctx, opts) if err != nil { slog.Warn("sql tx begin failed", "error", err.Error()) return fmt.Errorf("begin tx: %w", err) @@ -1067,6 +1086,14 @@ func (s *Store) InitSchemaContext(ctx context.Context) error { return fmt.Errorf("ensure idx_participants_phone unique: %w", err) } + // Seed the open communication-service catalog. The catalog is + // presentation and normalization metadata, not an enum: unknown bridges + // are registered as rows at runtime. The migration ledger prevents + // startup from fighting later user edits or deletions. + if err := s.seedCommunicationServices(ctx); err != nil { + return fmt.Errorf("seed communication services: %w", err) + } + // Migrations: add columns for databases created before these features. // The dialect determines the list. Both backends return ADD COLUMN // migrations for DBs created before later columns were introduced: @@ -1095,6 +1122,15 @@ func (s *Store) InitSchemaContext(ctx context.Context) error { lastModifiedColumnAdded = true } } + if err := s.ensureParticipantIdentifierServiceScopeIndex(ctx); err != nil { + return fmt.Errorf("create participant identifier service-scope index: %w", err) + } + + // This one-shot backfill must run after LegacyColumnMigrations so upgraded + // databases have the nullable service/scope columns before they are read. + if err := s.ensureParticipantIdentifierServiceScope(ctx); err != nil { + return fmt.Errorf("classify participant identifier service scope: %w", err) + } // Create the message watermark maintenance triggers. Must run after the // migration loop above, which adds last_modified and content_changed_at on diff --git a/internal/store/subset.go b/internal/store/subset.go index 5c3fa3259..856154e20 100644 --- a/internal/store/subset.go +++ b/internal/store/subset.go @@ -2,6 +2,7 @@ package store import ( "database/sql" + "errors" "fmt" "os" "path/filepath" @@ -26,6 +27,7 @@ type CopyResult struct { type CopySubsetOptions struct { IncludeIdentity bool IncludeAttributes bool + IncludeProfiles bool } // CopySubset copies rowCount most recent messages (and all referenced @@ -40,11 +42,12 @@ type CopySubsetOptions struct { // inside the subset (a partial profile under its original revision would // misrepresent curated data). includeIdentity opts in to the full identity // closure instead: participants are expanded through participant_links and -// shared person bindings until every included cluster and person profile +// shared person bindings until every included cluster and person binding set // is complete, which exposes identifiers of linked identities that have no -// messages in the subset. Person attribute definitions and values are not copied; -// callers sharing attributes must explicitly use CopySubsetWithOptions with -// IncludeAttributes. When attributes are included, person-valued references +// messages in the subset. Structured profile values and their provenance +// dependencies require the separate IncludeProfiles opt-in. Person attribute +// definitions and values also require callers to explicitly use +// CopySubsetWithOptions with IncludeAttributes. When attributes are included, person-valued references // follow the same boundary: references to excluded people are omitted by // default, while IncludeIdentity follows references from included people and // copies each target's complete identity profile. @@ -62,6 +65,9 @@ func CopySubset( // CopySubsetWithOptions copies a subset with explicitly selected sensitive // metadata. IncludeAttributes copies current and historical attribute values, // including their value content, provenance references, and actor metadata. +// IncludeProfiles copies current and historical structured profile values, +// media, contact observations, identity-review candidates and evidence, and +// their provenance dependencies. func CopySubsetWithOptions( srcDBPath, dstDir string, rowCount int, options CopySubsetOptions, ) (*CopyResult, error) { @@ -270,6 +276,11 @@ func copyData(tx *sql.Tx, rowCount int, options CopySubsetOptions) (*CopyResult, DESC, id DESC LIMIT ?`, LiveMessagesWhere("", true)), rowCount); err != nil { return nil, fmt.Errorf("select messages: %w", err) } + if _, err := tx.Exec(`CREATE TEMP TABLE selected_message_sources AS + SELECT DISTINCT source_id FROM src.messages + WHERE id IN (SELECT id FROM selected_messages)`); err != nil { + return nil, fmt.Errorf("select message sources: %w", err) + } // Try copying with oauth_app column first; fall back to NULL // for source databases created before this column existed. @@ -282,10 +293,7 @@ func copyData(tx *sql.Tx, rowCount int, options CopySubsetOptions) (*CopyResult, last_sync_at, sync_cursor, sync_config, oauth_app, created_at, updated_at FROM src.sources - WHERE id IN ( - SELECT DISTINCT source_id FROM src.messages - WHERE id IN (SELECT id FROM selected_messages) - )`) + WHERE id IN (SELECT source_id FROM selected_message_sources)`) if err != nil && isSQLiteError(err, "no such column") { res, err = tx.Exec(` INSERT INTO sources @@ -296,10 +304,7 @@ func copyData(tx *sql.Tx, rowCount int, options CopySubsetOptions) (*CopyResult, last_sync_at, sync_cursor, sync_config, NULL, created_at, updated_at FROM src.sources - WHERE id IN ( - SELECT DISTINCT source_id FROM src.messages - WHERE id IN (SELECT id FROM selected_messages) - )`) + WHERE id IN (SELECT source_id FROM selected_message_sources)`) } if err != nil { return nil, fmt.Errorf("copy sources: %w", err) @@ -430,6 +435,17 @@ func copyData(tx *sql.Tx, rowCount int, options CopySubsetOptions) (*CopyResult, AND participant_id IN (SELECT id FROM participants)`); err != nil { return nil, fmt.Errorf("copy person_participants: %w", err) } + if err := reconcileSubsetCommunicationServices(tx, options.IncludeProfiles); err != nil { + return nil, err + } + + if options.IncludeProfiles { + extraSources, err := copyStructuredProfiles(tx) + if err != nil { + return nil, err + } + result.Sources += extraSources + } if options.IncludeAttributes { // Definitions are portable by universal_id, not their database-local @@ -519,10 +535,8 @@ func copyData(tx *sql.Tx, rowCount int, options CopySubsetOptions) (*CopyResult, } } - if _, err := tx.Exec(` - INSERT INTO participant_identifiers - SELECT * FROM src.participant_identifiers - WHERE participant_id IN (SELECT id FROM participants)`); err != nil { + if err := copyByNameWithCommunicationServiceMap(tx, "participant_identifiers", + `participant_id IN (SELECT id FROM participants)`); err != nil { return nil, fmt.Errorf("copy participant_identifiers: %w", err) } @@ -624,7 +638,7 @@ func copyData(tx *sql.Tx, rowCount int, options CopySubsetOptions) (*CopyResult, return nil, fmt.Errorf("copy attachments: %w", err) } - res, err = copyByName(tx, "labels", `source_id IN (SELECT id FROM sources) + res, err = copyByName(tx, "labels", `source_id IN (SELECT source_id FROM selected_message_sources) OR id IN ( SELECT label_id FROM src.message_labels WHERE message_id IN (SELECT id FROM selected_messages) @@ -648,10 +662,236 @@ func copyData(tx *sql.Tx, rowCount int, options CopySubsetOptions) (*CopyResult, ); err != nil { return nil, fmt.Errorf("drop temp table: %w", err) } + if _, err := tx.Exec( + "DROP TABLE IF EXISTS selected_message_sources", + ); err != nil { + return nil, fmt.Errorf("drop source temp table: %w", err) + } return result, nil } +type subsetServiceReference struct { + table string + where string +} + +const subsetSourceIdentityMatchCandidateWhere = `( + (left_kind = 'participant' AND left_id IN (SELECT id FROM participants)) + OR (left_kind = 'person' AND left_id IN (SELECT id FROM persons)) + OR (left_kind = 'observation' AND left_id IN ( + SELECT id FROM src.participant_contact_observations + WHERE participant_id IN (SELECT id FROM participants) + )) + OR (left_kind = 'contact_point' AND left_id IN ( + SELECT id FROM src.person_contact_points + WHERE person_id IN (SELECT id FROM persons) + )) +) AND ( + (right_kind = 'participant' AND right_id IN (SELECT id FROM participants)) + OR (right_kind = 'person' AND right_id IN (SELECT id FROM persons)) + OR (right_kind = 'observation' AND right_id IN ( + SELECT id FROM src.participant_contact_observations + WHERE participant_id IN (SELECT id FROM participants) + )) + OR (right_kind = 'contact_point' AND right_id IN ( + SELECT id FROM src.person_contact_points + WHERE person_id IN (SELECT id FROM persons) + )) +)` + +// reconcileSubsetCommunicationServices copies every service referenced by a +// row that will cross the subset boundary. Service IDs are database-local, so +// the destination resolves them through the immutable slug and records an +// explicit source-to-destination map for the dependent row copies. +func reconcileSubsetCommunicationServices(tx *sql.Tx, includeProfiles bool) error { + if _, err := tx.Exec(`CREATE TEMP TABLE selected_profile_services ( + source_id INTEGER PRIMARY KEY + )`); err != nil { + return fmt.Errorf("create selected profile services: %w", err) + } + if _, err := tx.Exec(`CREATE TEMP TABLE selected_profile_service_map ( + source_id INTEGER PRIMARY KEY, + destination_id INTEGER NOT NULL + )`); err != nil { + return fmt.Errorf("create profile service map: %w", err) + } + + references := []subsetServiceReference{ + {table: "participant_identifiers", where: `participant_id IN (SELECT id FROM participants)`}, + } + if includeProfiles { + references = append(references, + subsetServiceReference{ + table: "person_contact_points", + where: `person_id IN (SELECT id FROM persons)`, + }, + subsetServiceReference{ + table: "participant_contact_observations", + where: `participant_id IN (SELECT id FROM participants)`, + }, + subsetServiceReference{ + table: "identity_match_candidates", + where: subsetSourceIdentityMatchCandidateWhere, + }, + ) + } + for _, reference := range references { + hasServiceID, err := sourceColumnExists(tx, reference.table, "service_id") + if err != nil { + return fmt.Errorf("check %s service column: %w", reference.table, err) + } + if !hasServiceID { + continue + } + if _, err := tx.Exec(`INSERT OR IGNORE INTO selected_profile_services (source_id) + SELECT service_id FROM src.` + reference.table + ` + WHERE ` + reference.where + ` AND service_id IS NOT NULL`); err != nil { + return fmt.Errorf("select %s services: %w", reference.table, err) + } + } + + hasServices, err := sourceTableExists(tx, "communication_services") + if err != nil { + return fmt.Errorf("check communication service schema: %w", err) + } + if !hasServices { + var selected int + if err := tx.QueryRow(`SELECT COUNT(*) FROM selected_profile_services`).Scan(&selected); err != nil { + return fmt.Errorf("count referenced communication services: %w", err) + } + if selected != 0 { + return errors.New("copy communication services: source catalog is missing") + } + return nil + } + var missing int + if err := tx.QueryRow(`SELECT COUNT(*) + FROM selected_profile_services selected + LEFT JOIN src.communication_services service ON service.id = selected.source_id + WHERE service.id IS NULL`).Scan(&missing); err != nil { + return fmt.Errorf("check referenced communication services: %w", err) + } + if missing != 0 { + return fmt.Errorf("copy communication services: %d referenced services are missing", missing) + } + + if _, err := tx.Exec(`INSERT INTO communication_services ( + slug, display_label, scope_policy, default_scope_kind, + normalization, normalization_version, uri_scheme, + profile_url_template, is_system, is_active, created_at, updated_at + ) + SELECT service.slug, service.display_label, service.scope_policy, + service.default_scope_kind, service.normalization, + service.normalization_version, service.uri_scheme, + service.profile_url_template, service.is_system, service.is_active, + service.created_at, service.updated_at + FROM src.communication_services service + JOIN selected_profile_services selected ON selected.source_id = service.id + ON CONFLICT(slug) DO UPDATE SET + display_label = excluded.display_label, + scope_policy = excluded.scope_policy, + default_scope_kind = excluded.default_scope_kind, + normalization = excluded.normalization, + normalization_version = excluded.normalization_version, + uri_scheme = excluded.uri_scheme, + profile_url_template = excluded.profile_url_template, + is_system = excluded.is_system, + is_active = excluded.is_active, + created_at = excluded.created_at, + updated_at = excluded.updated_at`); err != nil { + return fmt.Errorf("copy communication services: %w", err) + } + if _, err := tx.Exec(`INSERT INTO selected_profile_service_map (source_id, destination_id) + SELECT source.id, destination.id + FROM src.communication_services source + JOIN selected_profile_services selected ON selected.source_id = source.id + JOIN communication_services destination ON destination.slug = source.slug`); err != nil { + return fmt.Errorf("map communication services: %w", err) + } + + hasAliases, err := sourceTableExists(tx, "communication_service_aliases") + if err != nil { + return fmt.Errorf("check communication service alias schema: %w", err) + } + if hasAliases { + if _, err := tx.Exec(`INSERT INTO communication_service_aliases (alias, service_id) + SELECT alias.alias, service_map.destination_id + FROM src.communication_service_aliases alias + JOIN selected_profile_service_map service_map + ON service_map.source_id = alias.service_id + ON CONFLICT(alias) DO UPDATE SET service_id = excluded.service_id`); err != nil { + return fmt.Errorf("copy communication service aliases: %w", err) + } + } + return nil +} + +func copyStructuredProfiles(tx *sql.Tx) (int64, error) { + hasProfiles, err := sourceTableExists(tx, "person_names") + if err != nil { + return 0, fmt.Errorf("check structured profile schema: %w", err) + } + if !hasProfiles { + return 0, nil + } + + // Observations keep their source foreign key, even when that source had no + // selected message. A complete copied profile must retain that provenance. + sourceResult, err := copyByName(tx, "sources", `id IN ( + SELECT DISTINCT source_id FROM src.participant_contact_observations + WHERE participant_id IN (SELECT id FROM participants) + AND source_id IS NOT NULL + ) AND id NOT IN (SELECT id FROM sources)`) + if err != nil { + return 0, fmt.Errorf("copy structured profile sources: %w", err) + } + extraSources, err := sourceResult.RowsAffected() + if err != nil { + return 0, fmt.Errorf("structured profile sources rows affected: %w", err) + } + + for _, table := range []string{ + "person_names", "person_addresses", + "person_dates", "person_categories", "person_media", + } { + if _, err := copyByName(tx, table, `person_id IN (SELECT id FROM persons)`); err != nil { + return 0, fmt.Errorf("copy %s: %w", table, err) + } + } + if err := copyByNameWithCommunicationServiceMap( + tx, "person_contact_points", `person_id IN (SELECT id FROM persons)`, + ); err != nil { + return 0, fmt.Errorf("copy person_contact_points: %w", err) + } + if err := copyByNameWithCommunicationServiceMap(tx, "participant_contact_observations", + `participant_id IN (SELECT id FROM participants)`); err != nil { + return 0, fmt.Errorf("copy participant_contact_observations: %w", err) + } + hasCandidates, err := sourceTableExists(tx, "identity_match_candidates") + if err != nil { + return 0, fmt.Errorf("check identity match candidate schema: %w", err) + } + if hasCandidates { + if err := copyByNameWithCommunicationServiceMap( + tx, "identity_match_candidates", subsetSourceIdentityMatchCandidateWhere, + ); err != nil { + return 0, fmt.Errorf("copy identity_match_candidates: %w", err) + } + hasEvidence, err := sourceTableExists(tx, "identity_match_evidence") + if err != nil { + return 0, fmt.Errorf("check identity match evidence schema: %w", err) + } + if hasEvidence { + if _, err := copyByName(tx, "identity_match_evidence", + `candidate_id IN (SELECT id FROM identity_match_candidates)`); err != nil { + return 0, fmt.Errorf("copy identity_match_evidence: %w", err) + } + } + } + return extraSources, nil +} + // copyMessages copies the selected messages, naming the columns the source and // destination have in common, read from the two schemas at copy time. func copyMessages(tx *sql.Tx) error { @@ -694,6 +934,69 @@ func copyByName(tx *sql.Tx, table, where string, args ...any) (sql.Result, error return res, nil } +func copyByNameWithCommunicationServiceMap( + tx *sql.Tx, table, where string, +) error { + cols, err := commonColumns(tx, table) + if err != nil { + return err + } + if len(cols) == 0 { + return fmt.Errorf( + "source and destination share no %s columns", table) + } + selectExpressions := make([]string, len(cols)) + serviceColumn := quoteIdentifier("service_id") + hasServiceColumn := false + for index, column := range cols { + selectExpressions[index] = "source_row." + column + if column == serviceColumn { + hasServiceColumn = true + selectExpressions[index] = `CASE + WHEN source_row.` + serviceColumn + ` IS NULL THEN NULL + ELSE service_map.destination_id + END` + } + } + if !hasServiceColumn { + _, err := copyByName(tx, table, where) + return err + } + _, err = tx.Exec(fmt.Sprintf(` + INSERT INTO %s (%s) + SELECT %s FROM src.%s source_row + LEFT JOIN selected_profile_service_map service_map + ON service_map.source_id = source_row.%s + WHERE %s`, + table, strings.Join(cols, ", "), strings.Join(selectExpressions, ", "), + table, serviceColumn, where, + )) + return err +} + +func sourceTableExists(tx *sql.Tx, table string) (bool, error) { + var count int + if err := tx.QueryRow(`SELECT COUNT(*) FROM src.sqlite_master + WHERE type = 'table' AND name = ?`, table).Scan(&count); err != nil { + return false, err + } + return count > 0, nil +} + +func sourceColumnExists(tx *sql.Tx, table, column string) (bool, error) { + columns, err := schemaColumns(tx, "src", table) + if err != nil { + return false, err + } + foldedColumn := foldIdentifier(column) + for _, candidate := range columns { + if foldIdentifier(candidate) == foldedColumn { + return true, nil + } + } + return false, nil +} + // commonColumns returns the quoted names of the columns `table` has in both the // destination (main) and the attached source, in destination declaration order. // Names are matched the way SQLite matches identifiers — case-insensitively diff --git a/internal/store/subset_test.go b/internal/store/subset_test.go index 2a46c9516..3821282c6 100644 --- a/internal/store/subset_test.go +++ b/internal/store/subset_test.go @@ -313,6 +313,275 @@ func TestCopySubset_PreservesPersonProfiles(t *testing.T) { assert.Equal(person.ParticipantIDs, copied.ParticipantIDs) } +func TestCopySubset_ExcludesStructuredProfilesByDefault(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := t.Context() + srcDB := createTestSourceDB(t, t.TempDir(), 5) + source, err := Open(srcDB) + require.NoError(err) + person, _, err := source.CreatePersonFromParticipant(2) + require.NoError(err) + _, err = source.AddPersonNameContext(ctx, person.ID, PersonNameInput{ + NameKind: PersonNameFormatted, + Formatted: new("Private Profile Name"), + Envelope: ValueEnvelopeInput{Source: ProvenanceUser}, + }) + require.NoError(err) + require.NoError(source.Close()) + + dstDir := filepath.Join(t.TempDir(), "dst") + _, err = CopySubset(srcDB, dstDir, 5, false) + require.NoError(err) + destination, err := Open(filepath.Join(dstDir, "msgvault.db")) + require.NoError(err) + t.Cleanup(func() { _ = destination.Close() }) + + history, err := destination.GetPersonProfileHistoryContext(ctx, person.ID) + require.NoError(err) + assert.Empty(history.Names, + "a shared subset must not copy structured profile values without an explicit opt-in") +} + +func TestCopySubset_LegacyParticipantIdentifiersCopyByColumnName(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + srcDB := createTestSourceDB(t, t.TempDir(), 1) + + db, err := sql.Open("sqlite3", srcDB+"?_foreign_keys=OFF") + require.NoError(err) + _, err = db.Exec(` + DROP INDEX IF EXISTS idx_participant_identifiers_service_scope; + ALTER TABLE participant_identifiers RENAME TO participant_identifiers_current; + CREATE TABLE participant_identifiers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE, + identifier_type TEXT NOT NULL, + identifier_value TEXT NOT NULL, + display_value TEXT, + is_primary BOOLEAN NOT NULL DEFAULT FALSE, + UNIQUE(identifier_type, identifier_value) + ); + INSERT INTO participant_identifiers ( + id, participant_id, identifier_type, identifier_value, + display_value, is_primary + ) + SELECT id, participant_id, identifier_type, identifier_value, + display_value, is_primary + FROM participant_identifiers_current; + DROP TABLE participant_identifiers_current; + `) + require.NoError(err, "rebuild legacy participant_identifiers") + require.NoError(db.Close()) + + dstDir := filepath.Join(t.TempDir(), "dst") + _, err = CopySubset(srcDB, dstDir, 1, false) + require.NoError(err, "copy legacy participant identifiers") + destination, err := sql.Open("sqlite3", filepath.Join(dstDir, "msgvault.db")) + require.NoError(err) + t.Cleanup(func() { _ = destination.Close() }) + + var participantID int64 + var serviceID sql.NullInt64 + require.NoError(destination.QueryRow(`SELECT participant_id, service_id + FROM participant_identifiers + WHERE identifier_type = 'email' AND identifier_value = 'bob@example.com'`). + Scan(&participantID, &serviceID)) + assert.Equal(int64(2), participantID) + assert.False(serviceID.Valid, "missing legacy service metadata must use the destination default") +} + +func TestCopySubsetRemapsParticipantIdentifierServicesWithoutProfiles(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := t.Context() + srcDB := createTestSourceDB(t, t.TempDir(), 1) + source, err := Open(srcDB) + require.NoError(err) + + var sourceServiceID int64 + require.NoError(source.db.QueryRow( + `SELECT id FROM communication_services WHERE slug = 'whatsapp'`, + ).Scan(&sourceServiceID)) + _, err = source.db.Exec(`UPDATE communication_services + SET slug = 'subset-custom-chat', display_label = 'Subset Custom Chat', + is_system = FALSE + WHERE id = ?`, sourceServiceID) + require.NoError(err) + _, err = source.db.Exec(`UPDATE participant_identifiers + SET service_id = ?, scope_kind = 'account', scope_value = 'synthetic-account' + WHERE participant_id = 2`, sourceServiceID) + require.NoError(err) + require.NoError(source.Close()) + + dstDir := filepath.Join(t.TempDir(), "dst") + _, err = CopySubset(srcDB, dstDir, 1, false) + require.NoError(err) + destination, err := Open(filepath.Join(dstDir, "msgvault.db")) + require.NoError(err) + t.Cleanup(func() { _ = destination.Close() }) + + copiedService, err := destination.ResolveCommunicationServiceContext( + ctx, "subset-custom-chat", + ) + require.NoError(err) + var identifierServiceID int64 + require.NoError(destination.db.QueryRow(`SELECT service_id + FROM participant_identifiers WHERE participant_id = 2`).Scan(&identifierServiceID)) + assert.Equal(copiedService.ID, identifierServiceID) + assert.NotEqual(sourceServiceID, identifierServiceID, + "the destination service ID must be resolved from its immutable slug") +} + +func TestCopySubsetPreservesStructuredProfileHistoryAndDependencies(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := t.Context() + srcDB := createTestSourceDB(t, t.TempDir(), 5) + source, err := Open(srcDB) + require.NoError(err) + person, _, err := source.CreatePersonFromParticipant(2) + require.NoError(err) + _, _, err = source.EnsureCommunicationServiceContext(ctx, CommunicationServiceInput{ + Slug: "source-only-offset", DisplayLabel: "Source Only Offset", + ScopePolicy: ScopePolicyNone, Normalization: NormalizationNone, + NormalizationVersion: 1, + }) + require.NoError(err) + service, _, err := source.EnsureCommunicationServiceContext(ctx, CommunicationServiceInput{ + Slug: "example-chat", DisplayLabel: "Example Chat", Aliases: []string{"example-im"}, + ScopePolicy: ScopePolicyNone, Normalization: NormalizationLower, + NormalizationVersion: 1, + }) + require.NoError(err) + profileSource, err := source.GetOrCreateSource("profile-fixture", "profile-only") + require.NoError(err) + _, err = source.DB().Exec(`INSERT INTO labels ( + id, source_id, source_label_id, name, label_type + ) VALUES (?, ?, ?, ?, ?)`, + 9001, profileSource.ID, "profile-private", "Profile Private", "user", + ) + require.NoError(err) + + oldName, err := source.AddPersonNameContext(ctx, person.ID, PersonNameInput{ + NameKind: PersonNameFormatted, Formatted: new("Robert Example"), + Envelope: ValueEnvelopeInput{Source: ProvenanceVCardImport}, + }) + require.NoError(err) + require.NoError(source.SupersedePersonNameContext(ctx, person.ID, oldName.Envelope.ID, nil)) + _, err = source.AddPersonNameContext(ctx, person.ID, PersonNameInput{ + NameKind: PersonNameFormatted, Formatted: new("Bob Example"), + Envelope: ValueEnvelopeInput{Source: ProvenanceUser}, + }) + require.NoError(err) + _, err = source.AddPersonContactPointContext(ctx, person.ID, PersonContactPointInput{ + AddressKind: ContactAddressUsername, ServiceSlug: &service.Slug, + OriginalValue: "BobExample", Envelope: ValueEnvelopeInput{Source: ProvenanceUser}, + }) + require.NoError(err) + _, err = source.AddPersonAddressContext(ctx, person.ID, PersonAddressInput{ + AddressKind: PersonAddressPostal, StreetAddress: new("123 Example St"), + Envelope: ValueEnvelopeInput{Source: ProvenanceUser}, + }) + require.NoError(err) + _, err = source.AddPersonDateContext(ctx, person.ID, PersonDateInput{ + DateKind: PersonDateBirthday, Date: PartialDate{Year: new(1985), Month: new(4), Day: new(12)}, + Envelope: ValueEnvelopeInput{Source: ProvenanceUser}, + }) + require.NoError(err) + _, err = source.AddPersonCategoryContext(ctx, person.ID, PersonCategoryInput{ + OriginalValue: "Friends", Envelope: ValueEnvelopeInput{Source: ProvenanceUser}, + }) + require.NoError(err) + _, err = source.AddPersonMediaContext(ctx, person.ID, PersonMediaInput{ + MediaKind: PersonMediaPhoto, URI: new("https://example.invalid/photo.jpg"), + Envelope: ValueEnvelopeInput{Source: ProvenanceUser}, + }) + require.NoError(err) + firstObservation, err := source.RecordContactObservationContext(ctx, 2, ParticipantContactObservationInput{ + SourceID: &profileSource.ID, AddressKind: ContactAddressUsername, + ServiceSlug: &service.Slug, ProviderUserID: new("provider-bob"), + OriginalValue: "BobExample", + Envelope: ValueEnvelopeInput{Source: ProvenanceArchiveObservation}, + }) + require.NoError(err) + require.False(firstObservation.Conflicting) + secondObservation, err := source.RecordContactObservationContext( + ctx, 3, ParticipantContactObservationInput{ + SourceID: &profileSource.ID, AddressKind: ContactAddressUsername, + ServiceSlug: &service.Slug, ProviderUserID: new("provider-charlie"), + OriginalValue: "BobExample", + Envelope: ValueEnvelopeInput{Source: ProvenanceArchiveObservation}, + }, + ) + require.NoError(err) + require.True(secondObservation.Conflicting) + require.NotNil(secondObservation.CandidateID) + _, err = source.AddIdentityMatchEvidenceContext( + ctx, *secondObservation.CandidateID, IdentityMatchEvidenceInput{ + EvidenceKind: "shared_username", EvidenceRef: new("fixture-evidence"), + Detail: new("reviewed source observation"), Source: ProvenanceSystem, + }, + ) + require.NoError(err) + decisionNote := "keep identities separate" + decidedCandidate, err := source.DecideIdentityMatchCandidateContext( + ctx, *secondObservation.CandidateID, IdentityMatchStateRejected, + "user", &decisionNote, + ) + require.NoError(err) + require.NoError(source.Close()) + + dstDir := filepath.Join(t.TempDir(), "dst") + _, err = CopySubsetWithOptions(srcDB, dstDir, 1, CopySubsetOptions{ + IncludeProfiles: true, + }) + require.NoError(err) + destination, err := Open(filepath.Join(dstDir, "msgvault.db")) + require.NoError(err) + t.Cleanup(func() { _ = destination.Close() }) + + history, err := destination.GetPersonProfileHistoryContext(ctx, person.ID) + require.NoError(err) + assert.Len(history.Names, 2) + assert.Len(history.ContactPoints, 1) + assert.Len(history.Addresses, 1) + assert.Len(history.Dates, 1) + assert.Len(history.Categories, 1) + assert.Len(history.Media, 1) + assert.Len(history.Observations, 1) + copiedService, err := destination.ResolveCommunicationServiceContext(ctx, "example-im") + require.NoError(err) + assert.Equal("example-chat", copiedService.Slug) + assert.Equal("Example Chat", copiedService.DisplayLabel) + assert.NotEqual(service.ID, copiedService.ID, + "candidate service IDs must be remapped through the immutable slug") + copiedProfileSource, err := destination.GetSourceByID(profileSource.ID) + require.NoError(err) + assert.Equal("profile-only", copiedProfileSource.Identifier) + candidates, err := destination.ListIdentityMatchCandidatesContext(ctx, nil, 10, 0) + require.NoError(err) + require.Len(candidates, 1) + copiedCandidate := candidates[0] + assert.Equal(decidedCandidate.ID, copiedCandidate.ID) + assert.Equal(IdentityMatchStateRejected, copiedCandidate.State) + assert.Equal(decidedCandidate.DecidedBy, copiedCandidate.DecidedBy) + assert.Equal(decidedCandidate.DecidedAt, copiedCandidate.DecidedAt) + assert.Equal(decidedCandidate.Notes, copiedCandidate.Notes) + require.NotNil(copiedCandidate.ServiceSlug) + assert.Equal("example-chat", *copiedCandidate.ServiceSlug) + require.Len(copiedCandidate.Evidence, 1) + assert.Equal("shared_username", copiedCandidate.Evidence[0].EvidenceKind) + require.NotNil(copiedCandidate.Evidence[0].EvidenceRef) + assert.Equal("fixture-evidence", *copiedCandidate.Evidence[0].EvidenceRef) + var leakedProfileLabels int + require.NoError(destination.DB().QueryRow( + `SELECT COUNT(*) FROM labels WHERE source_id = ?`, profileSource.ID, + ).Scan(&leakedProfileLabels)) + assert.Zero(leakedProfileLabels, + "profile-only provenance must not broaden message label selection") +} + func TestCopySubset_AttributesRequireExplicitOptIn(t *testing.T) { assert := assert.New(t) require := require.New(t) diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index 1ce496830..a775a53cd 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -68,6 +68,46 @@ func TestGeneratedSourceIdentitiesPreserveRequiredEmptyArrays(t *testing.T) { ) } +func TestCreateCommunicationServiceAcceptsIdempotentOKResponse(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(http.MethodPost, r.Method) + assert.Equal("/api/v1/communication-services", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "id":42, + "slug":"example-chat", + "display_label":"Example Chat", + "aliases":[], + "scope_policy":"none", + "normalization":"lower", + "normalization_version":1, + "is_system":false, + "is_active":true, + "created_at":"2026-08-09T00:00:00Z", + "updated_at":"2026-08-09T00:00:00Z" + }`)) + })) + t.Cleanup(server.Close) + c, err := New(server.URL) + require.NoError(err) + + service, err := c.CreateCommunicationService( + context.Background(), &generated.CreateCommunicationServiceRequestOptions{ + Body: &generated.CreateCommunicationServiceBody{ + Slug: "example-chat", DisplayLabel: "Example Chat", + ScopePolicy: generated.CreateCommunicationServiceRequestScopePolicyNone, + Normalization: generated.CreateCommunicationServiceRequestNormalizationLower, + }, + }, + ) + require.NoError(err) + require.NotNil(service) + assert.Equal("example-chat", service.Slug) +} + func TestGeneratedEnumNamesPreserveSavedViewCompatibilityAndQualifyExploration(t *testing.T) { assertions := assert.New(t) assertions.Equal(generated.Asc, generated.SavedViewSortDirection("asc")) diff --git a/pkg/client/generated/client.go b/pkg/client/generated/client.go index c05250519..4a10f6cd4 100644 --- a/pkg/client/generated/client.go +++ b/pkg/client/generated/client.go @@ -239,6 +239,14 @@ type ClientInterface interface { VerifyCLI(ctx context.Context, options *VerifyCLIRequestOptions, reqEditors ...runtime.RequestEditorFn) (*VerifyCLIResponse, error) VerifyCLIWithResponse(ctx context.Context, options *VerifyCLIRequestOptions, reqEditors ...runtime.RequestEditorFn) (*VerifyCLIResp, error) + // ListCommunicationServices List communication services + ListCommunicationServices(ctx context.Context, options *ListCommunicationServicesRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListCommunicationServicesResponse, error) + ListCommunicationServicesWithResponse(ctx context.Context, options *ListCommunicationServicesRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListCommunicationServicesResp, error) + + // CreateCommunicationService Register a communication service + CreateCommunicationService(ctx context.Context, options *CreateCommunicationServiceRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CreateCommunicationServiceResponse, error) + CreateCommunicationServiceWithResponse(ctx context.Context, options *CreateCommunicationServiceRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CreateCommunicationServiceResp, error) + // GetRemoteImage Fetch a consented remote mail image through the SSRF-hardened daemon proxy GetRemoteImage(ctx context.Context, options *GetRemoteImageRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetRemoteImageResponse, error) GetRemoteImageWithResponse(ctx context.Context, options *GetRemoteImageRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetRemoteImageResp, error) @@ -435,6 +443,22 @@ type ClientInterface interface { SetPersonAttribute(ctx context.Context, options *SetPersonAttributeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SetPersonAttributeResponse, error) SetPersonAttributeWithResponse(ctx context.Context, options *SetPersonAttributeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SetPersonAttributeResp, error) + // GetPersonStructuredProfile Get a person's current structured profile + GetPersonStructuredProfile(ctx context.Context, options *GetPersonStructuredProfileRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonStructuredProfileResponse, error) + GetPersonStructuredProfileWithResponse(ctx context.Context, options *GetPersonStructuredProfileRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonStructuredProfileResp, error) + + // PatchPersonStructuredProfile Atomically patch a person's structured profile + PatchPersonStructuredProfile(ctx context.Context, options *PatchPersonStructuredProfileRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PatchPersonStructuredProfileResponse, error) + PatchPersonStructuredProfileWithResponse(ctx context.Context, options *PatchPersonStructuredProfileRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PatchPersonStructuredProfileResp, error) + + // GetPersonProfileHistory Get a person's structured profile history + GetPersonProfileHistory(ctx context.Context, options *GetPersonProfileHistoryRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonProfileHistoryResponse, error) + GetPersonProfileHistoryWithResponse(ctx context.Context, options *GetPersonProfileHistoryRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonProfileHistoryResp, error) + + // GetPersonProfileMediaContent Download stored inline content for one person profile media value + GetPersonProfileMediaContent(ctx context.Context, options *GetPersonProfileMediaContentRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonProfileMediaContentResponse, error) + GetPersonProfileMediaContentWithResponse(ctx context.Context, options *GetPersonProfileMediaContentRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonProfileMediaContentResp, error) + // RunQuery Run an aggregate query RunQuery(ctx context.Context, options *RunQueryRequestOptions, reqEditors ...runtime.RequestEditorFn) (*RunQueryResponse, error) RunQueryWithResponse(ctx context.Context, options *RunQueryRequestOptions, reqEditors ...runtime.RequestEditorFn) (*RunQueryResp, error) @@ -3677,6 +3701,133 @@ func (c *Client) VerifyCLI(ctx context.Context, options *VerifyCLIRequestOptions return responseParser(ctx, resp) } +// ListCommunicationServices List communication services +func (c *Client) ListCommunicationServices(ctx context.Context, options *ListCommunicationServicesRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListCommunicationServicesResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/communication-services", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*ListCommunicationServicesResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(ListCommunicationServicesErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListCommunicationServicesErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(ListCommunicationServicesResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListCommunicationServicesResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/communication-services") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// CreateCommunicationService Register a communication service +func (c *Client) CreateCommunicationService(ctx context.Context, options *CreateCommunicationServiceRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CreateCommunicationServiceResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/communication-services", + Method: "POST", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*CreateCommunicationServiceResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(CreateCommunicationServiceErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CreateCommunicationServiceErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(CreateCommunicationServiceResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CreateCommunicationServiceResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/communication-services") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + // GetRemoteImage Fetch a consented remote mail image through the SSRF-hardened daemon proxy func (c *Client) GetRemoteImage(ctx context.Context, options *GetRemoteImageRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetRemoteImageResponse, error) { var err error @@ -6741,6 +6892,245 @@ func (c *Client) SetPersonAttribute(ctx context.Context, options *SetPersonAttri return responseParser(ctx, resp) } +// GetPersonStructuredProfile Get a person's current structured profile +func (c *Client) GetPersonStructuredProfile(ctx context.Context, options *GetPersonStructuredProfileRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonStructuredProfileResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/profile", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*GetPersonStructuredProfileResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(GetPersonStructuredProfileErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonStructuredProfileErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(GetPersonStructuredProfileResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonStructuredProfileResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}/profile") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// PatchPersonStructuredProfile Atomically patch a person's structured profile +func (c *Client) PatchPersonStructuredProfile(ctx context.Context, options *PatchPersonStructuredProfileRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PatchPersonStructuredProfileResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/profile", + Method: "PATCH", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*PatchPersonStructuredProfileResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(PatchPersonStructuredProfileErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PatchPersonStructuredProfileErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(PatchPersonStructuredProfileResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PatchPersonStructuredProfileResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}/profile") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// GetPersonProfileHistory Get a person's structured profile history +func (c *Client) GetPersonProfileHistory(ctx context.Context, options *GetPersonProfileHistoryRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonProfileHistoryResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/profile/history", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*GetPersonProfileHistoryResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(GetPersonProfileHistoryErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonProfileHistoryErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(GetPersonProfileHistoryResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonProfileHistoryResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}/profile/history") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// GetPersonProfileMediaContent Download stored inline content for one person profile media value +func (c *Client) GetPersonProfileMediaContent(ctx context.Context, options *GetPersonProfileMediaContentRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonProfileMediaContentResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/profile/media/{media_id}/content", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*GetPersonProfileMediaContentResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(GetPersonProfileMediaContentErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonProfileMediaContentErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + result := GetPersonProfileMediaContentResponse(bodyBytes) + return &result, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}/profile/media/{media_id}/content") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + // RunQuery Run an aggregate query func (c *Client) RunQuery(ctx context.Context, options *RunQueryRequestOptions, reqEditors ...runtime.RequestEditorFn) (*RunQueryResponse, error) { var err error diff --git a/pkg/client/generated/client_options.go b/pkg/client/generated/client_options.go index 30d541584..b84e06bb2 100644 --- a/pkg/client/generated/client_options.go +++ b/pkg/client/generated/client_options.go @@ -1864,6 +1864,94 @@ func (o *VerifyCLIRequestOptions) GetHeader() (map[string]string, error) { return nil, nil } +// ListCommunicationServicesRequestOptions is the options needed to make a request to ListCommunicationServices. +type ListCommunicationServicesRequestOptions struct { + Query *ListCommunicationServicesQuery +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *ListCommunicationServicesRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.Query != nil { + if v, ok := any(o.Query).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Query", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *ListCommunicationServicesRequestOptions) GetPathParams() (map[string]any, error) { + return nil, nil +} + +// GetQuery returns the query params as a map. +func (o *ListCommunicationServicesRequestOptions) GetQuery() (map[string]any, error) { + return runtime.AsMap[any](o.Query) +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *ListCommunicationServicesRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *ListCommunicationServicesRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + +// CreateCommunicationServiceRequestOptions is the options needed to make a request to CreateCommunicationService. +type CreateCommunicationServiceRequestOptions struct { + Body *CreateCommunicationServiceBody +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *CreateCommunicationServiceRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.Body != nil { + if v, ok := any(o.Body).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Body", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *CreateCommunicationServiceRequestOptions) GetPathParams() (map[string]any, error) { + return nil, nil +} + +// GetQuery returns the query params as a map. +func (o *CreateCommunicationServiceRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *CreateCommunicationServiceRequestOptions) GetBody() any { + return o.Body +} + +// GetHeader returns the headers as a map. +func (o *CreateCommunicationServiceRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + // GetRemoteImageRequestOptions is the options needed to make a request to GetRemoteImage. type GetRemoteImageRequestOptions struct { Body *GetRemoteImageBody @@ -3997,6 +4085,200 @@ func (o *SetPersonAttributeRequestOptions) GetHeader() (map[string]string, error return nil, nil } +// GetPersonStructuredProfileRequestOptions is the options needed to make a request to GetPersonStructuredProfile. +type GetPersonStructuredProfileRequestOptions struct { + PathParams *GetPersonStructuredProfilePath +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *GetPersonStructuredProfileRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *GetPersonStructuredProfileRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *GetPersonStructuredProfileRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *GetPersonStructuredProfileRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *GetPersonStructuredProfileRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + +// PatchPersonStructuredProfileRequestOptions is the options needed to make a request to PatchPersonStructuredProfile. +type PatchPersonStructuredProfileRequestOptions struct { + PathParams *PatchPersonStructuredProfilePath + Body *PatchPersonStructuredProfileBody + Header *PatchPersonStructuredProfileHeaders +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *PatchPersonStructuredProfileRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + + if o.Body != nil { + if v, ok := any(o.Body).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Body", err) + } + } + } + + if o.Header != nil { + if v, ok := any(o.Header).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Header", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *PatchPersonStructuredProfileRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *PatchPersonStructuredProfileRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *PatchPersonStructuredProfileRequestOptions) GetBody() any { + return o.Body +} + +// GetHeader returns the headers as a map. +func (o *PatchPersonStructuredProfileRequestOptions) GetHeader() (map[string]string, error) { + return runtime.AsMap[string](o.Header) +} + +// GetPersonProfileHistoryRequestOptions is the options needed to make a request to GetPersonProfileHistory. +type GetPersonProfileHistoryRequestOptions struct { + PathParams *GetPersonProfileHistoryPath +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *GetPersonProfileHistoryRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *GetPersonProfileHistoryRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *GetPersonProfileHistoryRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *GetPersonProfileHistoryRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *GetPersonProfileHistoryRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + +// GetPersonProfileMediaContentRequestOptions is the options needed to make a request to GetPersonProfileMediaContent. +type GetPersonProfileMediaContentRequestOptions struct { + PathParams *GetPersonProfileMediaContentPath +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *GetPersonProfileMediaContentRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *GetPersonProfileMediaContentRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *GetPersonProfileMediaContentRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *GetPersonProfileMediaContentRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *GetPersonProfileMediaContentRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + // RunQueryRequestOptions is the options needed to make a request to RunQuery. type RunQueryRequestOptions struct { Body *RunQueryBody diff --git a/pkg/client/generated/client_with_response.go b/pkg/client/generated/client_with_response.go index 1010d43fb..64e447b5a 100644 --- a/pkg/client/generated/client_with_response.go +++ b/pkg/client/generated/client_with_response.go @@ -3746,6 +3746,197 @@ func (c *Client) VerifyCLIWithResponse(ctx context.Context, options *VerifyCLIRe } } +// ListCommunicationServices List communication services +func (c *Client) ListCommunicationServicesWithResponse(ctx context.Context, options *ListCommunicationServicesRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListCommunicationServicesResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/communication-services", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/communication-services") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &ListCommunicationServicesResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(ListCommunicationServicesResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListCommunicationServicesResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 400: + out.JSON400 = new(ListCommunicationServicesErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListCommunicationServicesErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(ListCommunicationServicesErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListCommunicationServicesErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// CreateCommunicationService Register a communication service +func (c *Client) CreateCommunicationServiceWithResponse(ctx context.Context, options *CreateCommunicationServiceRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CreateCommunicationServiceResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/communication-services", + Method: "POST", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/communication-services") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &CreateCommunicationServiceResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(CreateCommunicationServiceResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CreateCommunicationServiceResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 400: + out.JSON400 = new(CreateCommunicationServiceErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CreateCommunicationServiceErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(CreateCommunicationServiceErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CreateCommunicationServiceErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 409: + out.JSON409 = new(CreateCommunicationServiceErrorResponseJSON409) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CreateCommunicationServiceErrorResponseJSON409", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(CreateCommunicationServiceErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CreateCommunicationServiceErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + // GetRemoteImage Fetch a consented remote mail image through the SSRF-hardened daemon proxy func (c *Client) GetRemoteImageWithResponse(ctx context.Context, options *GetRemoteImageRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetRemoteImageResp, error) { var err error @@ -7589,6 +7780,459 @@ func (c *Client) SetPersonAttributeWithResponse(ctx context.Context, options *Se } } +// GetPersonStructuredProfile Get a person's current structured profile +func (c *Client) GetPersonStructuredProfileWithResponse(ctx context.Context, options *GetPersonStructuredProfileRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonStructuredProfileResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/profile", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}/profile") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &GetPersonStructuredProfileResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(GetPersonStructuredProfileResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonStructuredProfileResponse", + Body: bodyBytes, + Err: err, + } + } + } + out.Headers200 = &GetPersonStructuredProfileResp200Headers{ + ETag: resp.Headers.Get("ETag"), + } + return out, nil + case 400: + out.JSON400 = new(GetPersonStructuredProfileErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonStructuredProfileErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(GetPersonStructuredProfileErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonStructuredProfileErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(GetPersonStructuredProfileErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonStructuredProfileErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// PatchPersonStructuredProfile Atomically patch a person's structured profile +func (c *Client) PatchPersonStructuredProfileWithResponse(ctx context.Context, options *PatchPersonStructuredProfileRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PatchPersonStructuredProfileResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/profile", + Method: "PATCH", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}/profile") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &PatchPersonStructuredProfileResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(PatchPersonStructuredProfileResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PatchPersonStructuredProfileResponse", + Body: bodyBytes, + Err: err, + } + } + } + out.Headers200 = &PatchPersonStructuredProfileResp200Headers{ + ETag: resp.Headers.Get("ETag"), + } + return out, nil + case 400: + out.JSON400 = new(PatchPersonStructuredProfileErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PatchPersonStructuredProfileErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(PatchPersonStructuredProfileErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PatchPersonStructuredProfileErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 409: + out.JSON409 = new(PatchPersonStructuredProfileErrorResponseJSON409) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PatchPersonStructuredProfileErrorResponseJSON409", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 413: + out.JSON413 = new(PatchPersonStructuredProfileErrorResponseJSON413) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON413); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PatchPersonStructuredProfileErrorResponseJSON413", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 428: + out.JSON428 = new(PatchPersonStructuredProfileErrorResponseJSON428) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON428); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PatchPersonStructuredProfileErrorResponseJSON428", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(PatchPersonStructuredProfileErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PatchPersonStructuredProfileErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// GetPersonProfileHistory Get a person's structured profile history +func (c *Client) GetPersonProfileHistoryWithResponse(ctx context.Context, options *GetPersonProfileHistoryRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonProfileHistoryResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/profile/history", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}/profile/history") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &GetPersonProfileHistoryResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(GetPersonProfileHistoryResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonProfileHistoryResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 400: + out.JSON400 = new(GetPersonProfileHistoryErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonProfileHistoryErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(GetPersonProfileHistoryErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonProfileHistoryErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(GetPersonProfileHistoryErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonProfileHistoryErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// GetPersonProfileMediaContent Download stored inline content for one person profile media value +func (c *Client) GetPersonProfileMediaContentWithResponse(ctx context.Context, options *GetPersonProfileMediaContentRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonProfileMediaContentResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/profile/media/{media_id}/content", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}/profile/media/{media_id}/content") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &GetPersonProfileMediaContentResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + return out, nil + case 400: + out.JSON400 = new(GetPersonProfileMediaContentErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonProfileMediaContentErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 401: + out.JSON401 = new(GetPersonProfileMediaContentErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON401); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonProfileMediaContentErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(GetPersonProfileMediaContentErrorResponseJSON404) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonProfileMediaContentErrorResponseJSON404", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 500: + out.JSON500 = new(GetPersonProfileMediaContentErrorResponseJSON500) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonProfileMediaContentErrorResponseJSON500", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(GetPersonProfileMediaContentErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonProfileMediaContentErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + // RunQuery Run an aggregate query func (c *Client) RunQueryWithResponse(ctx context.Context, options *RunQueryRequestOptions, reqEditors ...runtime.RequestEditorFn) (*RunQueryResp, error) { var err error diff --git a/pkg/client/generated/enums.go b/pkg/client/generated/enums.go index 442b2d3d4..ae8281e51 100644 --- a/pkg/client/generated/enums.go +++ b/pkg/client/generated/enums.go @@ -77,6 +77,45 @@ func (c CreateAttributeDefinitionRequestObjectType) Validate() error { } } +type CreateCommunicationServiceRequestNormalization string + +const ( + CreateCommunicationServiceRequestNormalizationByAddressKind CreateCommunicationServiceRequestNormalization = "by_address_kind" + CreateCommunicationServiceRequestNormalizationEmail CreateCommunicationServiceRequestNormalization = "email" + CreateCommunicationServiceRequestNormalizationLower CreateCommunicationServiceRequestNormalization = "lower" + CreateCommunicationServiceRequestNormalizationNone CreateCommunicationServiceRequestNormalization = "none" + CreateCommunicationServiceRequestNormalizationPhoneE164 CreateCommunicationServiceRequestNormalization = "phone_e164" + CreateCommunicationServiceRequestNormalizationStripAtLower CreateCommunicationServiceRequestNormalization = "strip_at_lower" +) + +// Validate checks if the CreateCommunicationServiceRequestNormalization value is valid +func (c CreateCommunicationServiceRequestNormalization) Validate() error { + switch c { + case CreateCommunicationServiceRequestNormalizationByAddressKind, CreateCommunicationServiceRequestNormalizationEmail, CreateCommunicationServiceRequestNormalizationLower, CreateCommunicationServiceRequestNormalizationNone, CreateCommunicationServiceRequestNormalizationPhoneE164, CreateCommunicationServiceRequestNormalizationStripAtLower: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid CreateCommunicationServiceRequestNormalization value, got: %v", c)) + } +} + +type CreateCommunicationServiceRequestScopePolicy string + +const ( + CreateCommunicationServiceRequestScopePolicyNone CreateCommunicationServiceRequestScopePolicy = "none" + CreateCommunicationServiceRequestScopePolicyOptional CreateCommunicationServiceRequestScopePolicy = "optional" + CreateCommunicationServiceRequestScopePolicyRequired CreateCommunicationServiceRequestScopePolicy = "required" +) + +// Validate checks if the CreateCommunicationServiceRequestScopePolicy value is valid +func (c CreateCommunicationServiceRequestScopePolicy) Validate() error { + switch c { + case CreateCommunicationServiceRequestScopePolicyNone, CreateCommunicationServiceRequestScopePolicyOptional, CreateCommunicationServiceRequestScopePolicyRequired: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid CreateCommunicationServiceRequestScopePolicy value, got: %v", c)) + } +} + type DiscoverEventType string const ( diff --git a/pkg/client/generated/headers.go b/pkg/client/generated/headers.go index 9c0a2e2b4..b3366635a 100644 --- a/pkg/client/generated/headers.go +++ b/pkg/client/generated/headers.go @@ -51,6 +51,15 @@ func (p PatchPersonHeaders) Validate() error { return runtime.ConvertValidatorError(typesValidator.Struct(p)) } +type PatchPersonStructuredProfileHeaders struct { + // IfMatch Strong ETag returned by the latest person profile read. Must be the exact single tag from that read; the RFC 7232 forms `*` and comma-separated tag lists are not supported. + IfMatch string `json:"If-Match" validate:"required"` +} + +func (p PatchPersonStructuredProfileHeaders) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + type DeleteSavedViewHeaders struct { // IfMatch Strong ETag returned by the latest Saved View read IfMatch string `json:"If-Match" validate:"required"` diff --git a/pkg/client/generated/paths.go b/pkg/client/generated/paths.go index 5e51707f1..0619e6271 100644 --- a/pkg/client/generated/paths.go +++ b/pkg/client/generated/paths.go @@ -236,6 +236,29 @@ func (s SetPersonAttributePath) Validate() error { return runtime.ConvertValidatorError(typesValidator.Struct(s)) } +type GetPersonStructuredProfilePath struct { + // ID Durable person ID + ID int64 `json:"id"` +} + +type PatchPersonStructuredProfilePath struct { + // ID Durable person ID + ID int64 `json:"id"` +} + +type GetPersonProfileHistoryPath struct { + // ID Durable person ID + ID int64 `json:"id"` +} + +type GetPersonProfileMediaContentPath struct { + // ID Durable person ID + ID int64 `json:"id"` + + // MediaID Structured person profile media value ID + MediaID int64 `json:"media_id"` +} + type GetRelationshipTimelinePath struct { // ID Any member participant ID of the counterpart's identity cluster ID int64 `json:"id"` diff --git a/pkg/client/generated/payloads.go b/pkg/client/generated/payloads.go index 048e90a65..767121ce2 100644 --- a/pkg/client/generated/payloads.go +++ b/pkg/client/generated/payloads.go @@ -46,6 +46,8 @@ type ImportCLIIdentitiesBody = ImportRequest type RunCLIBody = CLIRunRequest +type CreateCommunicationServiceBody = CreateCommunicationServiceRequest + type GetRemoteImageBody = RemoteImageRequest type StageDeletionBody = StageDeletionRequest @@ -94,6 +96,8 @@ type PatchPersonBody = PatchPersonRequest type SetPersonAttributeBody = SetPersonAttributeRequest +type PatchPersonStructuredProfileBody = PersonProfilePatchRequest + type RunQueryBody = QueryRequest type ListRelationshipsBody = RelationshipsHTTPRequest diff --git a/pkg/client/generated/queries.go b/pkg/client/generated/queries.go index fd58835dd..afd9aa177 100644 --- a/pkg/client/generated/queries.go +++ b/pkg/client/generated/queries.go @@ -263,6 +263,11 @@ func (v VerifyCLIQuery) Validate() error { return runtime.ConvertValidatorError(typesValidator.Struct(v)) } +type ListCommunicationServicesQuery struct { + // IncludeInactive Include inactive catalog entries + IncludeInactive *bool `json:"include_inactive,omitempty"` +} + type GetConversationQuery struct { // Anchor Selected message ID anchoring the chronological window Anchor int64 `json:"anchor"` diff --git a/pkg/client/generated/responses.go b/pkg/client/generated/responses.go index 1dd93dc85..cfe83d31f 100644 --- a/pkg/client/generated/responses.go +++ b/pkg/client/generated/responses.go @@ -347,6 +347,22 @@ type VerifyCLIResponse = []byte type VerifyCLIErrorResponse = ErrorResponse +type ListCommunicationServicesResponse = CommunicationServicesResponse + +type ListCommunicationServicesErrorResponse = ErrorResponse + +type ListCommunicationServicesErrorResponseJSON = ErrorResponse + +type CreateCommunicationServiceResponse = CommunicationService + +type CreateCommunicationServiceErrorResponse = ErrorResponse + +type CreateCommunicationServiceErrorResponseJSON = ErrorResponse + +type CreateCommunicationServiceErrorResponseJSON409 = ErrorResponse + +type CreateCommunicationServiceErrorResponseJSON503 = ErrorResponse + type GetRemoteImageResponse = []byte type GetRemoteImageErrorResponse = ErrorResponse @@ -1217,6 +1233,48 @@ type SetPersonAttributeErrorResponseJSON409 = ErrorResponse type SetPersonAttributeErrorResponseJSON503 = ErrorResponse +type GetPersonStructuredProfileResponse = StructuredPersonProfile + +type GetPersonStructuredProfileErrorResponse = ErrorResponse + +type GetPersonStructuredProfileErrorResponseJSON = ErrorResponse + +type GetPersonStructuredProfileErrorResponseJSON503 = ErrorResponse + +type PatchPersonStructuredProfileResponse = StructuredPersonProfile + +type PatchPersonStructuredProfileErrorResponse = ErrorResponse + +type PatchPersonStructuredProfileErrorResponseJSON = ErrorResponse + +type PatchPersonStructuredProfileErrorResponseJSON409 = ErrorResponse + +type PatchPersonStructuredProfileErrorResponseJSON413 = ErrorResponse + +type PatchPersonStructuredProfileErrorResponseJSON428 = ErrorResponse + +type PatchPersonStructuredProfileErrorResponseJSON503 = ErrorResponse + +type GetPersonProfileHistoryResponse = PersonProfileHistory + +type GetPersonProfileHistoryErrorResponse = ErrorResponse + +type GetPersonProfileHistoryErrorResponseJSON = ErrorResponse + +type GetPersonProfileHistoryErrorResponseJSON503 = ErrorResponse + +type GetPersonProfileMediaContentResponse = []byte + +type GetPersonProfileMediaContentErrorResponse = ErrorResponse + +type GetPersonProfileMediaContentErrorResponseJSON = ErrorResponse + +type GetPersonProfileMediaContentErrorResponseJSON404 = ErrorResponse + +type GetPersonProfileMediaContentErrorResponseJSON500 = ErrorResponse + +type GetPersonProfileMediaContentErrorResponseJSON503 = ErrorResponse + type RunQueryResponse = QueryResult type RunQueryErrorResponse = ErrorResponse @@ -1932,6 +1990,26 @@ type VerifyCLIResp struct { StatusCode int } +type ListCommunicationServicesResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *ListCommunicationServicesResponse + JSON400 *ListCommunicationServicesErrorResponse + JSON503 *ListCommunicationServicesErrorResponseJSON +} + +type CreateCommunicationServiceResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *CreateCommunicationServiceResponse + JSON400 *CreateCommunicationServiceErrorResponse + JSON404 *CreateCommunicationServiceErrorResponseJSON + JSON409 *CreateCommunicationServiceErrorResponseJSON409 + JSON503 *CreateCommunicationServiceErrorResponseJSON503 +} + type GetRemoteImageResp struct { HTTPResponse *http.Response Body []byte @@ -2386,6 +2464,60 @@ type SetPersonAttributeResp struct { JSON503 *SetPersonAttributeErrorResponseJSON503 } +type GetPersonStructuredProfileResp200Headers struct { + ETag string `header:"ETag"` +} + +type GetPersonStructuredProfileResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *GetPersonStructuredProfileResponse + Headers200 *GetPersonStructuredProfileResp200Headers + JSON400 *GetPersonStructuredProfileErrorResponse + JSON404 *GetPersonStructuredProfileErrorResponseJSON + JSON503 *GetPersonStructuredProfileErrorResponseJSON503 +} + +type PatchPersonStructuredProfileResp200Headers struct { + ETag string `header:"ETag"` +} + +type PatchPersonStructuredProfileResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *PatchPersonStructuredProfileResponse + Headers200 *PatchPersonStructuredProfileResp200Headers + JSON400 *PatchPersonStructuredProfileErrorResponse + JSON404 *PatchPersonStructuredProfileErrorResponseJSON + JSON409 *PatchPersonStructuredProfileErrorResponseJSON409 + JSON413 *PatchPersonStructuredProfileErrorResponseJSON413 + JSON428 *PatchPersonStructuredProfileErrorResponseJSON428 + JSON503 *PatchPersonStructuredProfileErrorResponseJSON503 +} + +type GetPersonProfileHistoryResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *GetPersonProfileHistoryResponse + JSON400 *GetPersonProfileHistoryErrorResponse + JSON404 *GetPersonProfileHistoryErrorResponseJSON + JSON503 *GetPersonProfileHistoryErrorResponseJSON503 +} + +type GetPersonProfileMediaContentResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON400 *GetPersonProfileMediaContentErrorResponse + JSON401 *GetPersonProfileMediaContentErrorResponseJSON + JSON404 *GetPersonProfileMediaContentErrorResponseJSON404 + JSON500 *GetPersonProfileMediaContentErrorResponseJSON500 + JSON503 *GetPersonProfileMediaContentErrorResponseJSON503 +} + type RunQueryResp struct { HTTPResponse *http.Response Body []byte diff --git a/pkg/client/generated/types.go b/pkg/client/generated/types.go index 07d06d4be..76afe4371 100644 --- a/pkg/client/generated/types.go +++ b/pkg/client/generated/types.go @@ -1164,6 +1164,46 @@ func (c CliStatsResponse) Validate() error { return errors } +type CommunicationService struct { + Aliases []string `json:"aliases,omitempty" validate:"required"` + CreatedAt time.Time `json:"created_at" validate:"required"` + DefaultScopeKind *string `json:"default_scope_kind,omitempty"` + DisplayLabel string `json:"display_label" validate:"required"` + ID int64 `json:"id"` + IsActive bool `json:"is_active"` + IsSystem bool `json:"is_system"` + Normalization string `json:"normalization" validate:"required"` + NormalizationVersion int64 `json:"normalization_version"` + ProfileURLTemplate *string `json:"profile_url_template,omitempty"` + ScopePolicy string `json:"scope_policy" validate:"required"` + Slug string `json:"slug" validate:"required"` + UpdatedAt time.Time `json:"updated_at" validate:"required"` + URIScheme *string `json:"uri_scheme,omitempty"` +} + +func (c CommunicationService) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(c)) +} + +type CommunicationServicesResponse struct { + Services []CommunicationService `json:"services,omitempty" validate:"required"` +} + +func (c CommunicationServicesResponse) Validate() error { + var errors runtime.ValidationErrors + for i, item := range c.Services { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Services[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + type ConversationResponse struct { AnchorID int64 `json:"anchor_id"` HasAfter bool `json:"has_after"` @@ -1244,6 +1284,42 @@ func (c CreateAttributeDefinitionRequest) Validate() error { return errors } +type CreateCommunicationServiceRequest struct { + Aliases []string `json:"aliases,omitempty"` + DefaultScopeKind *string `json:"default_scope_kind,omitempty"` + DisplayLabel string `json:"display_label" validate:"required"` + Normalization CreateCommunicationServiceRequestNormalization `json:"normalization" validate:"required"` + NormalizationVersion *int64 `json:"normalization_version,omitempty"` + ProfileURLTemplate *string `json:"profile_url_template,omitempty"` + ScopePolicy CreateCommunicationServiceRequestScopePolicy `json:"scope_policy" validate:"required"` + Slug string `json:"slug" validate:"required"` + URIScheme *string `json:"uri_scheme,omitempty"` +} + +func (c CreateCommunicationServiceRequest) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(c.DisplayLabel, "required"); err != nil { + errors = errors.Append("DisplayLabel", err) + } + if v, ok := any(c.Normalization).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Normalization", err) + } + } + if v, ok := any(c.ScopePolicy).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("ScopePolicy", err) + } + } + if err := typesValidator.Var(c.Slug, "required"); err != nil { + errors = errors.Append("Slug", err) + } + if len(errors) == 0 { + return nil + } + return errors +} + type CreatePersonRequest struct { ParticipantID int64 `json:"participant_id"` } @@ -3174,6 +3250,53 @@ type OperationHealth struct { StartedAt *time.Time `json:"started_at,omitempty"` } +type PartialDate struct { + Day *int64 `json:"day,omitempty"` + Month *int64 `json:"month,omitempty"` + Year *int64 `json:"year,omitempty"` +} + +type ParticipantContactObservation struct { + AddressKind string `json:"address_kind" validate:"required"` + Envelope ValueEnvelope `json:"envelope"` + Normalization string `json:"normalization" validate:"required"` + NormalizationVersion int64 `json:"normalization_version"` + NormalizedValue string `json:"normalized_value" validate:"required"` + ObservedAt *time.Time `json:"observed_at,omitempty"` + OriginalValue string `json:"original_value" validate:"required"` + ParticipantID int64 `json:"participant_id"` + ProviderUserID *string `json:"provider_user_id,omitempty"` + ScopeKind *string `json:"scope_kind,omitempty"` + ScopeValue *string `json:"scope_value,omitempty"` + ServiceSlug *string `json:"service_slug,omitempty"` + SourceID *int64 `json:"source_id,omitempty"` +} + +func (p ParticipantContactObservation) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(p.AddressKind, "required"); err != nil { + errors = errors.Append("AddressKind", err) + } + if v, ok := any(p.Envelope).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Envelope", err) + } + } + if err := typesValidator.Var(p.Normalization, "required"); err != nil { + errors = errors.Append("Normalization", err) + } + if err := typesValidator.Var(p.NormalizedValue, "required"); err != nil { + errors = errors.Append("NormalizedValue", err) + } + if err := typesValidator.Var(p.OriginalValue, "required"); err != nil { + errors = errors.Append("OriginalValue", err) + } + if len(errors) == 0 { + return nil + } + return errors +} + type PatchAttributeDefinitionRequest struct { Description *string `json:"description,omitempty"` DisplayOrder *int64 `json:"display_order,omitempty"` @@ -3225,6 +3348,102 @@ func (p Person) Validate() error { return runtime.ConvertValidatorError(typesValidator.Struct(p)) } +type PersonAddress struct { + AddressKind string `json:"address_kind" validate:"required"` + CountryCode *string `json:"country_code,omitempty"` + CountryName *string `json:"country_name,omitempty"` + Envelope ValueEnvelope `json:"envelope"` + ExtendedAddress *string `json:"extended_address,omitempty"` + ExtendedComponents *string `json:"extended_components,omitempty"` + FreeText *string `json:"free_text,omitempty"` + GeoURI *string `json:"geo_uri,omitempty"` + Label *string `json:"label,omitempty"` + Locality *string `json:"locality,omitempty"` + OriginalValue string `json:"original_value" validate:"required"` + PersonID int64 `json:"person_id"` + PlaceURI *string `json:"place_uri,omitempty"` + PostOfficeBox *string `json:"post_office_box,omitempty"` + PostalCode *string `json:"postal_code,omitempty"` + Region *string `json:"region,omitempty"` + StreetAddress *string `json:"street_address,omitempty"` + Timezone *string `json:"timezone,omitempty"` +} + +func (p PersonAddress) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(p.AddressKind, "required"); err != nil { + errors = errors.Append("AddressKind", err) + } + if v, ok := any(p.Envelope).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Envelope", err) + } + } + if err := typesValidator.Var(p.OriginalValue, "required"); err != nil { + errors = errors.Append("OriginalValue", err) + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonAddressInputRequest struct { + AddressKind string `json:"address_kind" validate:"required"` + CountryCode *string `json:"country_code,omitempty"` + CountryName *string `json:"country_name,omitempty"` + Envelope ValueEnvelopeInput `json:"envelope"` + ExtendedAddress *string `json:"extended_address,omitempty"` + ExtendedComponents *string `json:"extended_components,omitempty"` + FreeText *string `json:"free_text,omitempty"` + GeoURI *string `json:"geo_uri,omitempty"` + Label *string `json:"label,omitempty"` + Locality *string `json:"locality,omitempty"` + OriginalValue *string `json:"original_value,omitempty"` + PlaceURI *string `json:"place_uri,omitempty"` + PostOfficeBox *string `json:"post_office_box,omitempty"` + PostalCode *string `json:"postal_code,omitempty"` + Region *string `json:"region,omitempty"` + StreetAddress *string `json:"street_address,omitempty"` + Timezone *string `json:"timezone,omitempty"` +} + +func (p PersonAddressInputRequest) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(p.AddressKind, "required"); err != nil { + errors = errors.Append("AddressKind", err) + } + if v, ok := any(p.Envelope).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Envelope", err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonAddressPatchRequest struct { + Add []PersonAddressInputRequest `json:"add,omitempty"` + Supersede []int64 `json:"supersede,omitempty"` +} + +func (p PersonAddressPatchRequest) Validate() error { + var errors runtime.ValidationErrors + for i, item := range p.Add { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Add[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + type PersonAttributeGroup struct { Current []PersonAttributeValue `json:"current,omitempty" validate:"required"` Definition AttributeDefinition `json:"definition"` @@ -3348,6 +3567,73 @@ func (p PersonAttributesResponse) Validate() error { return errors } +type PersonCategory struct { + Envelope ValueEnvelope `json:"envelope"` + NormalizedValue string `json:"normalized_value" validate:"required"` + OriginalValue string `json:"original_value" validate:"required"` + PersonID int64 `json:"person_id"` +} + +func (p PersonCategory) Validate() error { + var errors runtime.ValidationErrors + if v, ok := any(p.Envelope).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Envelope", err) + } + } + if err := typesValidator.Var(p.NormalizedValue, "required"); err != nil { + errors = errors.Append("NormalizedValue", err) + } + if err := typesValidator.Var(p.OriginalValue, "required"); err != nil { + errors = errors.Append("OriginalValue", err) + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonCategoryInputRequest struct { + Envelope ValueEnvelopeInput `json:"envelope"` + OriginalValue string `json:"original_value" validate:"required"` +} + +func (p PersonCategoryInputRequest) Validate() error { + var errors runtime.ValidationErrors + if v, ok := any(p.Envelope).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Envelope", err) + } + } + if err := typesValidator.Var(p.OriginalValue, "required"); err != nil { + errors = errors.Append("OriginalValue", err) + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonCategoryPatchRequest struct { + Add []PersonCategoryInputRequest `json:"add,omitempty"` + Supersede []int64 `json:"supersede,omitempty"` +} + +func (p PersonCategoryPatchRequest) Validate() error { + var errors runtime.ValidationErrors + for i, item := range p.Add { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Add[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + type PersonCluster struct { CanonicalID int64 `json:"canonical_id"` Edges []PersonClusterEdge `json:"edges,omitempty" validate:"required"` @@ -3377,6 +3663,94 @@ type PersonClusterEdge struct { ParticipantB int64 `json:"participant_b"` } +type PersonContactPoint struct { + AddressKind string `json:"address_kind" validate:"required"` + Envelope ValueEnvelope `json:"envelope"` + Normalization string `json:"normalization" validate:"required"` + NormalizationVersion int64 `json:"normalization_version"` + NormalizedValue string `json:"normalized_value" validate:"required"` + OriginalValue string `json:"original_value" validate:"required"` + PersonID int64 `json:"person_id"` + ScopeKind *string `json:"scope_kind,omitempty"` + ScopeValue *string `json:"scope_value,omitempty"` + ServiceSlug *string `json:"service_slug,omitempty"` + URI *string `json:"uri,omitempty"` +} + +func (p PersonContactPoint) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(p.AddressKind, "required"); err != nil { + errors = errors.Append("AddressKind", err) + } + if v, ok := any(p.Envelope).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Envelope", err) + } + } + if err := typesValidator.Var(p.Normalization, "required"); err != nil { + errors = errors.Append("Normalization", err) + } + if err := typesValidator.Var(p.NormalizedValue, "required"); err != nil { + errors = errors.Append("NormalizedValue", err) + } + if err := typesValidator.Var(p.OriginalValue, "required"); err != nil { + errors = errors.Append("OriginalValue", err) + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonContactPointInputRequest struct { + AddressKind string `json:"address_kind" validate:"required"` + Envelope ValueEnvelopeInput `json:"envelope"` + OriginalValue string `json:"original_value" validate:"required"` + ScopeKind *string `json:"scope_kind,omitempty"` + ScopeValue *string `json:"scope_value,omitempty"` + ServiceSlug *string `json:"service_slug,omitempty"` + URI *string `json:"uri,omitempty"` +} + +func (p PersonContactPointInputRequest) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(p.AddressKind, "required"); err != nil { + errors = errors.Append("AddressKind", err) + } + if v, ok := any(p.Envelope).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Envelope", err) + } + } + if err := typesValidator.Var(p.OriginalValue, "required"); err != nil { + errors = errors.Append("OriginalValue", err) + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonContactPointPatchRequest struct { + Add []PersonContactPointInputRequest `json:"add,omitempty"` + Supersede []int64 `json:"supersede,omitempty"` +} + +func (p PersonContactPointPatchRequest) Validate() error { + var errors runtime.ValidationErrors + for i, item := range p.Add { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Add[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + type PersonContextSummaryHTTPResponse struct { CacheRevision string `json:"cache_revision" validate:"required"` CandidateSnapshotID *string `json:"candidate_snapshot_id,omitempty"` @@ -3405,6 +3779,94 @@ func (p PersonContextSummaryHTTPResponse) Validate() error { return errors } +type PersonDate struct { + CalendarScale *string `json:"calendar_scale,omitempty"` + Date PartialDate `json:"date"` + DateKind string `json:"date_kind" validate:"required"` + DateText *string `json:"date_text,omitempty"` + Envelope ValueEnvelope `json:"envelope"` + Label *string `json:"label,omitempty"` + OriginalValue string `json:"original_value" validate:"required"` + PersonID int64 `json:"person_id"` +} + +func (p PersonDate) Validate() error { + var errors runtime.ValidationErrors + if v, ok := any(p.Date).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Date", err) + } + } + if err := typesValidator.Var(p.DateKind, "required"); err != nil { + errors = errors.Append("DateKind", err) + } + if v, ok := any(p.Envelope).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Envelope", err) + } + } + if err := typesValidator.Var(p.OriginalValue, "required"); err != nil { + errors = errors.Append("OriginalValue", err) + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonDateInputRequest struct { + CalendarScale *string `json:"calendar_scale,omitempty"` + Date *PartialDate `json:"date,omitempty"` + DateKind string `json:"date_kind" validate:"required"` + DateText *string `json:"date_text,omitempty"` + Envelope ValueEnvelopeInput `json:"envelope"` + Label *string `json:"label,omitempty"` + OriginalValue *string `json:"original_value,omitempty"` +} + +func (p PersonDateInputRequest) Validate() error { + var errors runtime.ValidationErrors + if p.Date != nil { + if v, ok := any(p.Date).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Date", err) + } + } + } + if err := typesValidator.Var(p.DateKind, "required"); err != nil { + errors = errors.Append("DateKind", err) + } + if v, ok := any(p.Envelope).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Envelope", err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonDatePatchRequest struct { + Add []PersonDateInputRequest `json:"add,omitempty"` + Supersede []int64 `json:"supersede,omitempty"` +} + +func (p PersonDatePatchRequest) Validate() error { + var errors runtime.ValidationErrors + for i, item := range p.Add { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Add[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + type PersonIdentifier struct { DisplayValue *string `json:"display_value,omitempty"` IsPrimary bool `json:"is_primary"` @@ -3418,12 +3880,316 @@ func (p PersonIdentifier) Validate() error { return runtime.ConvertValidatorError(typesValidator.Struct(p)) } +type PersonMedia struct { + ByteSize *int64 `json:"byte_size,omitempty"` + ContentHash *string `json:"content_hash,omitempty"` + Envelope ValueEnvelope `json:"envelope"` + HasData bool `json:"has_data"` + MediaKind string `json:"media_kind" validate:"required"` + MediaType *string `json:"media_type,omitempty"` + OriginalValue string `json:"original_value" validate:"required"` + PersonID int64 `json:"person_id"` + URI *string `json:"uri,omitempty"` +} + +func (p PersonMedia) Validate() error { + var errors runtime.ValidationErrors + if v, ok := any(p.Envelope).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Envelope", err) + } + } + if err := typesValidator.Var(p.MediaKind, "required"); err != nil { + errors = errors.Append("MediaKind", err) + } + if err := typesValidator.Var(p.OriginalValue, "required"); err != nil { + errors = errors.Append("OriginalValue", err) + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonMediaInputRequest struct { + Data *string `json:"data,omitempty"` + Envelope ValueEnvelopeInput `json:"envelope"` + MediaKind string `json:"media_kind" validate:"required"` + MediaType *string `json:"media_type,omitempty"` + OriginalValue *string `json:"original_value,omitempty"` + URI *string `json:"uri,omitempty"` +} + +func (p PersonMediaInputRequest) Validate() error { + var errors runtime.ValidationErrors + if v, ok := any(p.Envelope).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Envelope", err) + } + } + if err := typesValidator.Var(p.MediaKind, "required"); err != nil { + errors = errors.Append("MediaKind", err) + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonMediaPatchRequest struct { + Add []PersonMediaInputRequest `json:"add,omitempty"` + Supersede []int64 `json:"supersede,omitempty"` +} + +func (p PersonMediaPatchRequest) Validate() error { + var errors runtime.ValidationErrors + for i, item := range p.Add { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Add[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonName struct { + AdditionalNames *string `json:"additional_names,omitempty"` + Envelope ValueEnvelope `json:"envelope"` + FamilyName *string `json:"family_name,omitempty"` + Formatted *string `json:"formatted,omitempty"` + Generation *string `json:"generation,omitempty"` + GivenName *string `json:"given_name,omitempty"` + HonorificPrefixes *string `json:"honorific_prefixes,omitempty"` + HonorificSuffixes *string `json:"honorific_suffixes,omitempty"` + IsDerived bool `json:"is_derived"` + Language *string `json:"language,omitempty"` + NameKind string `json:"name_kind" validate:"required"` + OriginalValue string `json:"original_value" validate:"required"` + PersonID int64 `json:"person_id"` + PhoneticScript *string `json:"phonetic_script,omitempty"` + PhoneticSystem *string `json:"phonetic_system,omitempty"` + Script *string `json:"script,omitempty"` + SecondarySurname *string `json:"secondary_surname,omitempty"` + SortAs *string `json:"sort_as,omitempty"` +} + +func (p PersonName) Validate() error { + var errors runtime.ValidationErrors + if v, ok := any(p.Envelope).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Envelope", err) + } + } + if err := typesValidator.Var(p.NameKind, "required"); err != nil { + errors = errors.Append("NameKind", err) + } + if err := typesValidator.Var(p.OriginalValue, "required"); err != nil { + errors = errors.Append("OriginalValue", err) + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonNameInputRequest struct { + AdditionalNames *string `json:"additional_names,omitempty"` + Envelope ValueEnvelopeInput `json:"envelope"` + FamilyName *string `json:"family_name,omitempty"` + Formatted *string `json:"formatted,omitempty"` + Generation *string `json:"generation,omitempty"` + GivenName *string `json:"given_name,omitempty"` + HonorificPrefixes *string `json:"honorific_prefixes,omitempty"` + HonorificSuffixes *string `json:"honorific_suffixes,omitempty"` + IsDerived *bool `json:"is_derived,omitempty"` + Language *string `json:"language,omitempty"` + NameKind string `json:"name_kind" validate:"required"` + OriginalValue *string `json:"original_value,omitempty"` + PhoneticScript *string `json:"phonetic_script,omitempty"` + PhoneticSystem *string `json:"phonetic_system,omitempty"` + Script *string `json:"script,omitempty"` + SecondarySurname *string `json:"secondary_surname,omitempty"` + SortAs *string `json:"sort_as,omitempty"` +} + +func (p PersonNameInputRequest) Validate() error { + var errors runtime.ValidationErrors + if v, ok := any(p.Envelope).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Envelope", err) + } + } + if err := typesValidator.Var(p.NameKind, "required"); err != nil { + errors = errors.Append("NameKind", err) + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonNamePatchRequest struct { + Add []PersonNameInputRequest `json:"add,omitempty"` + Supersede []int64 `json:"supersede,omitempty"` +} + +func (p PersonNamePatchRequest) Validate() error { + var errors runtime.ValidationErrors + for i, item := range p.Add { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Add[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + type PersonProfile struct { DisplayName *string `json:"display_name,omitempty"` ID int64 `json:"id"` Revision int64 `json:"revision"` } +type PersonProfileHistory struct { + Addresses []PersonAddress `json:"addresses,omitempty" validate:"required"` + Categories []PersonCategory `json:"categories,omitempty" validate:"required"` + ContactPoints []PersonContactPoint `json:"contact_points,omitempty" validate:"required"` + Dates []PersonDate `json:"dates,omitempty" validate:"required"` + Media []PersonMedia `json:"media,omitempty" validate:"required"` + Names []PersonName `json:"names,omitempty" validate:"required"` + Observations []ParticipantContactObservation `json:"observations,omitempty" validate:"required"` + Person Person `json:"person"` +} + +func (p PersonProfileHistory) Validate() error { + var errors runtime.ValidationErrors + for i, item := range p.Addresses { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Addresses[%d]", i), err) + } + } + } + for i, item := range p.Categories { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Categories[%d]", i), err) + } + } + } + for i, item := range p.ContactPoints { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("ContactPoints[%d]", i), err) + } + } + } + for i, item := range p.Dates { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Dates[%d]", i), err) + } + } + } + for i, item := range p.Media { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Media[%d]", i), err) + } + } + } + for i, item := range p.Names { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Names[%d]", i), err) + } + } + } + for i, item := range p.Observations { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Observations[%d]", i), err) + } + } + } + if v, ok := any(p.Person).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Person", err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonProfilePatchRequest struct { + Addresses *PersonAddressPatchRequest `json:"addresses,omitempty"` + Categories *PersonCategoryPatchRequest `json:"categories,omitempty"` + ContactPoints *PersonContactPointPatchRequest `json:"contact_points,omitempty"` + Dates *PersonDatePatchRequest `json:"dates,omitempty"` + Media *PersonMediaPatchRequest `json:"media,omitempty"` + Names *PersonNamePatchRequest `json:"names,omitempty"` +} + +func (p PersonProfilePatchRequest) Validate() error { + var errors runtime.ValidationErrors + if p.Addresses != nil { + if v, ok := any(p.Addresses).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Addresses", err) + } + } + } + if p.Categories != nil { + if v, ok := any(p.Categories).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Categories", err) + } + } + } + if p.ContactPoints != nil { + if v, ok := any(p.ContactPoints).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("ContactPoints", err) + } + } + } + if p.Dates != nil { + if v, ok := any(p.Dates).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Dates", err) + } + } + } + if p.Media != nil { + if v, ok := any(p.Media).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Media", err) + } + } + } + if p.Names != nil { + if v, ok := any(p.Names).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Names", err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + type PersonSearchHTTPResponse struct { CacheRevision string `json:"cache_revision" validate:"required"` CandidateSnapshotID *string `json:"candidate_snapshot_id,omitempty"` @@ -4604,6 +5370,71 @@ func (s StatusMessageResponse) Validate() error { return runtime.ConvertValidatorError(typesValidator.Struct(s)) } +type StructuredPersonProfile struct { + Addresses []PersonAddress `json:"addresses,omitempty" validate:"required"` + Categories []PersonCategory `json:"categories,omitempty" validate:"required"` + ContactPoints []PersonContactPoint `json:"contact_points,omitempty" validate:"required"` + Dates []PersonDate `json:"dates,omitempty" validate:"required"` + Media []PersonMedia `json:"media,omitempty" validate:"required"` + Names []PersonName `json:"names,omitempty" validate:"required"` + Person Person `json:"person"` +} + +func (s StructuredPersonProfile) Validate() error { + var errors runtime.ValidationErrors + for i, item := range s.Addresses { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Addresses[%d]", i), err) + } + } + } + for i, item := range s.Categories { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Categories[%d]", i), err) + } + } + } + for i, item := range s.ContactPoints { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("ContactPoints[%d]", i), err) + } + } + } + for i, item := range s.Dates { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Dates[%d]", i), err) + } + } + } + for i, item := range s.Media { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Media[%d]", i), err) + } + } + } + for i, item := range s.Names { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Names[%d]", i), err) + } + } + } + if v, ok := any(s.Person).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Person", err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} + type Summary struct { Accounts []string `json:"accounts,omitempty" validate:"required"` DateRange []string `json:"date_range,omitempty" validate:"required"` @@ -4991,6 +5822,89 @@ func (u UpdateResult) Validate() error { return runtime.ConvertValidatorError(typesValidator.Struct(u)) } +type VCardIdentity struct { + Altid *string `json:"altid,omitempty"` + Group *string `json:"group,omitempty"` + Pid []string `json:"pid,omitempty"` + PropID *string `json:"prop_id,omitempty"` + Property *string `json:"property,omitempty"` +} + +type ValueEnvelope struct { + ActiveFrom *time.Time `json:"active_from,omitempty"` + ActiveUntil *time.Time `json:"active_until,omitempty"` + Confidence *float64 `json:"confidence,omitempty"` + CreatedAt time.Time `json:"created_at" validate:"required"` + ID int64 `json:"id"` + Ordinal int64 `json:"ordinal"` + Pref *int64 `json:"pref,omitempty"` + Source string `json:"source" validate:"required"` + SourceRef *string `json:"source_ref,omitempty"` + SupersededAt *time.Time `json:"superseded_at,omitempty"` + TypeLabel *string `json:"type_label,omitempty"` + TypeTokens []string `json:"type_tokens,omitempty"` + UpdatedAt time.Time `json:"updated_at" validate:"required"` + Vcard VCardIdentity `json:"vcard"` +} + +func (v ValueEnvelope) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(v.CreatedAt, "required"); err != nil { + errors = errors.Append("CreatedAt", err) + } + if err := typesValidator.Var(v.Source, "required"); err != nil { + errors = errors.Append("Source", err) + } + if err := typesValidator.Var(v.UpdatedAt, "required"); err != nil { + errors = errors.Append("UpdatedAt", err) + } + if v, ok := any(v.Vcard).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Vcard", err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type ValueEnvelopeInput struct { + ActiveFrom *time.Time `json:"active_from,omitempty"` + ActiveUntil *time.Time `json:"active_until,omitempty"` + Confidence *float64 `json:"confidence,omitempty"` + Ordinal *int64 `json:"ordinal,omitempty" validate:"omitempty,gte=0"` + Pref *int64 `json:"pref,omitempty"` + Source string `json:"source" validate:"required"` + SourceRef *string `json:"source_ref,omitempty"` + TypeLabel *string `json:"type_label,omitempty"` + TypeTokens []string `json:"type_tokens,omitempty"` + Vcard *VCardIdentity `json:"vcard,omitempty"` +} + +func (v ValueEnvelopeInput) Validate() error { + var errors runtime.ValidationErrors + if v.Ordinal != nil { + if err := typesValidator.Var(v.Ordinal, "omitempty,gte=0"); err != nil { + errors = errors.Append("Ordinal", err) + } + } + if err := typesValidator.Var(v.Source, "required"); err != nil { + errors = errors.Append("Source", err) + } + if v.Vcard != nil { + if v, ok := any(v.Vcard).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Vcard", err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + type VectorHealth struct { ErrorData *string `json:"error,omitempty"` Status string `json:"status" validate:"required"` diff --git a/pkg/client/openapi.yaml b/pkg/client/openapi.yaml index 939263bf0..f4a135962 100644 --- a/pkg/client/openapi.yaml +++ b/pkg/client/openapi.yaml @@ -1312,6 +1312,66 @@ components: required: - stats type: object + CommunicationService: + properties: + aliases: + items: + type: string + nullable: true + type: array + created_at: + format: date-time + type: string + default_scope_kind: + type: string + display_label: + type: string + id: + format: int64 + type: integer + is_active: + type: boolean + is_system: + type: boolean + normalization: + type: string + normalization_version: + format: int64 + type: integer + profile_url_template: + type: string + scope_policy: + type: string + slug: + type: string + updated_at: + format: date-time + type: string + uri_scheme: + type: string + required: + - id + - slug + - display_label + - aliases + - scope_policy + - normalization + - normalization_version + - is_system + - is_active + - created_at + - updated_at + type: object + CommunicationServicesResponse: + properties: + services: + items: + $ref: "#/components/schemas/CommunicationService" + nullable: true + type: array + required: + - services + type: object ConversationResponse: properties: anchor_id: @@ -1385,6 +1445,59 @@ components: - value_type - field_type type: object + CreateCommunicationServiceRequest: + additionalProperties: false + properties: + aliases: + items: + type: string + nullable: true + type: array + default_scope_kind: + type: string + display_label: + type: string + normalization: + enum: + - none + - lower + - email + - phone_e164 + - strip_at_lower + - by_address_kind + type: string + x-enum-names: + - CreateCommunicationServiceRequestNormalizationNone + - CreateCommunicationServiceRequestNormalizationLower + - CreateCommunicationServiceRequestNormalizationEmail + - CreateCommunicationServiceRequestNormalizationPhoneE164 + - CreateCommunicationServiceRequestNormalizationStripAtLower + - CreateCommunicationServiceRequestNormalizationByAddressKind + normalization_version: + format: int64 + type: integer + profile_url_template: + type: string + scope_policy: + enum: + - none + - optional + - required + type: string + x-enum-names: + - CreateCommunicationServiceRequestScopePolicyNone + - CreateCommunicationServiceRequestScopePolicyOptional + - CreateCommunicationServiceRequestScopePolicyRequired + slug: + type: string + uri_scheme: + type: string + required: + - slug + - display_label + - scope_policy + - normalization + type: object CreatePersonRequest: additionalProperties: false properties: @@ -3332,6 +3445,60 @@ components: required: - busy type: object + PartialDate: + additionalProperties: false + properties: + day: + format: int64 + type: integer + month: + format: int64 + type: integer + year: + format: int64 + type: integer + type: object + ParticipantContactObservation: + properties: + address_kind: + type: string + envelope: + $ref: "#/components/schemas/ValueEnvelope" + normalization: + type: string + normalization_version: + format: int64 + type: integer + normalized_value: + type: string + observed_at: + format: date-time + type: string + original_value: + type: string + participant_id: + format: int64 + type: integer + provider_user_id: + type: string + scope_kind: + type: string + scope_value: + type: string + service_slug: + type: string + source_id: + format: int64 + type: integer + required: + - envelope + - participant_id + - address_kind + - original_value + - normalized_value + - normalization + - normalization_version + type: object PatchAttributeDefinitionRequest: additionalProperties: false properties: @@ -3403,6 +3570,107 @@ components: - created_at - updated_at type: object + PersonAddress: + properties: + address_kind: + type: string + country_code: + type: string + country_name: + type: string + envelope: + $ref: "#/components/schemas/ValueEnvelope" + extended_address: + type: string + extended_components: + type: string + free_text: + type: string + geo_uri: + type: string + label: + type: string + locality: + type: string + original_value: + type: string + person_id: + format: int64 + type: integer + place_uri: + type: string + post_office_box: + type: string + postal_code: + type: string + region: + type: string + street_address: + type: string + timezone: + type: string + required: + - envelope + - person_id + - address_kind + - original_value + type: object + PersonAddressInputRequest: + additionalProperties: false + properties: + address_kind: + type: string + country_code: + type: string + country_name: + type: string + envelope: + $ref: "#/components/schemas/ValueEnvelopeInput" + extended_address: + type: string + extended_components: + type: string + free_text: + type: string + geo_uri: + type: string + label: + type: string + locality: + type: string + original_value: + type: string + place_uri: + type: string + post_office_box: + type: string + postal_code: + type: string + region: + type: string + street_address: + type: string + timezone: + type: string + required: + - address_kind + - envelope + type: object + PersonAddressPatchRequest: + additionalProperties: false + properties: + add: + items: + $ref: "#/components/schemas/PersonAddressInputRequest" + nullable: true + type: array + supersede: + items: + format: int64 + type: integer + nullable: true + type: array + type: object PersonAttributeGroup: properties: current: @@ -3496,6 +3764,49 @@ components: - person_id - attributes type: object + PersonCategory: + properties: + envelope: + $ref: "#/components/schemas/ValueEnvelope" + normalized_value: + type: string + original_value: + type: string + person_id: + format: int64 + type: integer + required: + - envelope + - person_id + - original_value + - normalized_value + type: object + PersonCategoryInputRequest: + additionalProperties: false + properties: + envelope: + $ref: "#/components/schemas/ValueEnvelopeInput" + original_value: + type: string + required: + - original_value + - envelope + type: object + PersonCategoryPatchRequest: + additionalProperties: false + properties: + add: + items: + $ref: "#/components/schemas/PersonCategoryInputRequest" + nullable: true + type: array + supersede: + items: + format: int64 + type: integer + nullable: true + type: array + type: object PersonCluster: properties: canonical_id: @@ -3525,46 +3836,346 @@ components: participant_b: format: int64 type: integer - required: - - participant_a - - participant_b - type: object - PersonContextSummaryHTTPResponse: - properties: - cache_revision: + required: + - participant_a + - participant_b + type: object + PersonContactPoint: + properties: + address_kind: + type: string + envelope: + $ref: "#/components/schemas/ValueEnvelope" + normalization: + type: string + normalization_version: + format: int64 + type: integer + normalized_value: + type: string + original_value: + type: string + person_id: + format: int64 + type: integer + scope_kind: + type: string + scope_value: + type: string + service_slug: + type: string + uri: + type: string + required: + - envelope + - person_id + - address_kind + - original_value + - normalized_value + - normalization + - normalization_version + type: object + PersonContactPointInputRequest: + additionalProperties: false + properties: + address_kind: + type: string + envelope: + $ref: "#/components/schemas/ValueEnvelopeInput" + original_value: + type: string + scope_kind: + type: string + scope_value: + type: string + service_slug: + type: string + uri: + type: string + required: + - address_kind + - original_value + - envelope + type: object + PersonContactPointPatchRequest: + additionalProperties: false + properties: + add: + items: + $ref: "#/components/schemas/PersonContactPointInputRequest" + nullable: true + type: array + supersede: + items: + format: int64 + type: integer + nullable: true + type: array + type: object + PersonContextSummaryHTTPResponse: + properties: + cache_revision: + type: string + candidate_snapshot_id: + type: string + search_provenance: + $ref: "#/components/schemas/SearchProvenance" + summary: + $ref: "#/components/schemas/PersonSummary" + required: + - summary + - cache_revision + - search_provenance + type: object + PersonDate: + properties: + calendar_scale: + type: string + date: + $ref: "#/components/schemas/PartialDate" + date_kind: + type: string + date_text: + type: string + envelope: + $ref: "#/components/schemas/ValueEnvelope" + label: + type: string + original_value: + type: string + person_id: + format: int64 + type: integer + required: + - envelope + - person_id + - date_kind + - date + - original_value + type: object + PersonDateInputRequest: + additionalProperties: false + properties: + calendar_scale: + type: string + date: + $ref: "#/components/schemas/PartialDate" + date_kind: + type: string + date_text: + type: string + envelope: + $ref: "#/components/schemas/ValueEnvelopeInput" + label: + type: string + original_value: + type: string + required: + - date_kind + - envelope + type: object + PersonDatePatchRequest: + additionalProperties: false + properties: + add: + items: + $ref: "#/components/schemas/PersonDateInputRequest" + nullable: true + type: array + supersede: + items: + format: int64 + type: integer + nullable: true + type: array + type: object + PersonIdentifier: + properties: + display_value: + type: string + is_primary: + type: boolean + participant_id: + format: int64 + type: integer + provenance: + type: string + type: + type: string + value: + type: string + required: + - type + - value + - is_primary + - provenance + - participant_id + type: object + PersonMedia: + properties: + byte_size: + format: int64 + type: integer + content_hash: + type: string + envelope: + $ref: "#/components/schemas/ValueEnvelope" + has_data: + type: boolean + media_kind: + type: string + media_type: + type: string + original_value: + type: string + person_id: + format: int64 + type: integer + uri: + type: string + required: + - envelope + - person_id + - media_kind + - has_data + - original_value + type: object + PersonMediaInputRequest: + additionalProperties: false + properties: + data: + format: base64 + type: string + envelope: + $ref: "#/components/schemas/ValueEnvelopeInput" + media_kind: + type: string + media_type: + type: string + original_value: + type: string + uri: + type: string + required: + - media_kind + - envelope + type: object + PersonMediaPatchRequest: + additionalProperties: false + properties: + add: + items: + $ref: "#/components/schemas/PersonMediaInputRequest" + nullable: true + type: array + supersede: + items: + format: int64 + type: integer + nullable: true + type: array + type: object + PersonName: + properties: + additional_names: + type: string + envelope: + $ref: "#/components/schemas/ValueEnvelope" + family_name: + type: string + formatted: + type: string + generation: + type: string + given_name: + type: string + honorific_prefixes: + type: string + honorific_suffixes: + type: string + is_derived: + type: boolean + language: + type: string + name_kind: + type: string + original_value: + type: string + person_id: + format: int64 + type: integer + phonetic_script: type: string - candidate_snapshot_id: + phonetic_system: + type: string + script: + type: string + secondary_surname: + type: string + sort_as: type: string - search_provenance: - $ref: "#/components/schemas/SearchProvenance" - summary: - $ref: "#/components/schemas/PersonSummary" required: - - summary - - cache_revision - - search_provenance + - envelope + - person_id + - name_kind + - is_derived + - original_value type: object - PersonIdentifier: + PersonNameInputRequest: + additionalProperties: false properties: - display_value: + additional_names: type: string - is_primary: + envelope: + $ref: "#/components/schemas/ValueEnvelopeInput" + family_name: + type: string + formatted: + type: string + generation: + type: string + given_name: + type: string + honorific_prefixes: + type: string + honorific_suffixes: + type: string + is_derived: type: boolean - participant_id: - format: int64 - type: integer - provenance: + language: type: string - type: + name_kind: type: string - value: + original_value: + type: string + phonetic_script: + type: string + phonetic_system: + type: string + script: + type: string + secondary_surname: + type: string + sort_as: type: string required: - - type - - value - - is_primary - - provenance - - participant_id + - name_kind + - envelope + type: object + PersonNamePatchRequest: + additionalProperties: false + properties: + add: + items: + $ref: "#/components/schemas/PersonNameInputRequest" + nullable: true + type: array + supersede: + items: + format: int64 + type: integer + nullable: true + type: array type: object PersonProfile: properties: @@ -3580,6 +4191,71 @@ components: - id - revision type: object + PersonProfileHistory: + properties: + addresses: + items: + $ref: "#/components/schemas/PersonAddress" + nullable: true + type: array + categories: + items: + $ref: "#/components/schemas/PersonCategory" + nullable: true + type: array + contact_points: + items: + $ref: "#/components/schemas/PersonContactPoint" + nullable: true + type: array + dates: + items: + $ref: "#/components/schemas/PersonDate" + nullable: true + type: array + media: + items: + $ref: "#/components/schemas/PersonMedia" + nullable: true + type: array + names: + items: + $ref: "#/components/schemas/PersonName" + nullable: true + type: array + observations: + items: + $ref: "#/components/schemas/ParticipantContactObservation" + nullable: true + type: array + person: + $ref: "#/components/schemas/Person" + required: + - person + - names + - contact_points + - addresses + - dates + - categories + - media + - observations + type: object + PersonProfilePatchRequest: + additionalProperties: false + properties: + addresses: + $ref: "#/components/schemas/PersonAddressPatchRequest" + categories: + $ref: "#/components/schemas/PersonCategoryPatchRequest" + contact_points: + $ref: "#/components/schemas/PersonContactPointPatchRequest" + dates: + $ref: "#/components/schemas/PersonDatePatchRequest" + media: + $ref: "#/components/schemas/PersonMediaPatchRequest" + names: + $ref: "#/components/schemas/PersonNamePatchRequest" + type: object PersonSearchHTTPResponse: properties: cache_revision: @@ -4670,6 +5346,49 @@ components: - status - message type: object + StructuredPersonProfile: + properties: + addresses: + items: + $ref: "#/components/schemas/PersonAddress" + nullable: true + type: array + categories: + items: + $ref: "#/components/schemas/PersonCategory" + nullable: true + type: array + contact_points: + items: + $ref: "#/components/schemas/PersonContactPoint" + nullable: true + type: array + dates: + items: + $ref: "#/components/schemas/PersonDate" + nullable: true + type: array + media: + items: + $ref: "#/components/schemas/PersonMedia" + nullable: true + type: array + names: + items: + $ref: "#/components/schemas/PersonName" + nullable: true + type: array + person: + $ref: "#/components/schemas/Person" + required: + - person + - names + - contact_points + - addresses + - dates + - categories + - media + type: object Summary: additionalProperties: false properties: @@ -5153,6 +5872,108 @@ components: - email - display_name type: object + VCardIdentity: + additionalProperties: false + properties: + altid: + type: string + group: + type: string + pid: + items: + type: string + nullable: true + type: array + prop_id: + type: string + property: + type: string + type: object + ValueEnvelope: + properties: + active_from: + format: date-time + type: string + active_until: + format: date-time + type: string + confidence: + format: double + type: number + created_at: + format: date-time + type: string + id: + format: int64 + type: integer + ordinal: + format: int64 + type: integer + pref: + format: int64 + type: integer + source: + type: string + source_ref: + type: string + superseded_at: + format: date-time + type: string + type_label: + type: string + type_tokens: + items: + type: string + nullable: true + type: array + updated_at: + format: date-time + type: string + vcard: + $ref: "#/components/schemas/VCardIdentity" + required: + - id + - ordinal + - vcard + - source + - created_at + - updated_at + type: object + ValueEnvelopeInput: + additionalProperties: false + properties: + active_from: + format: date-time + type: string + active_until: + format: date-time + type: string + confidence: + format: double + type: number + ordinal: + format: int64 + minimum: 0 + type: integer + pref: + format: int64 + type: integer + source: + type: string + source_ref: + type: string + type_label: + type: string + type_tokens: + items: + type: string + nullable: true + type: array + vcard: + $ref: "#/components/schemas/VCardIdentity" + required: + - source + type: object VectorHealth: properties: error: @@ -5169,7 +5990,7 @@ components: type: apiKey info: title: msgvault API - version: 1.36.0 + version: 1.38.0 openapi: 3.0.3 paths: /api/ping: @@ -7294,8 +8115,100 @@ paths: content: application/x-ndjson: schema: - $ref: "#/components/schemas/CLIVerifyEvent" + $ref: "#/components/schemas/CLIVerifyEvent" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Verify the CLI archive against Gmail + tags: + - API + /api/v1/communication-services: + get: + description: Lists the small open service catalog without pagination, including aliases and normalization policy. + operationId: listCommunicationServices + parameters: + - description: Include inactive catalog entries + in: query + name: include_inactive + schema: + default: false + type: boolean + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/CommunicationServicesResponse" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: List communication services + tags: + - API + post: + description: Registers an unknown or custom service without a schema migration. Re-registering a slug is idempotent. + operationId: createCommunicationService + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreateCommunicationServiceRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/CommunicationService" description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error default: content: application/json: @@ -7304,7 +8217,7 @@ paths: description: Error security: - apiKey: [] - summary: Verify the CLI archive against Gmail + summary: Register a communication service tags: - API /api/v1/content/remote-image: @@ -9635,6 +10548,258 @@ paths: summary: Set a person's attribute value tags: - API + /api/v1/persons/{id}/profile: + get: + description: Returns only current structured values at one person revision. Superseded values and archive observations are available from the separate history endpoint. + operationId: getPersonStructuredProfile + parameters: + - description: Durable person ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/StructuredPersonProfile" + description: OK + headers: + ETag: + description: Strong person profile revision tag for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Get a person's current structured profile + tags: + - API + patch: + description: Applies up to 200 explicit adds and supersedes atomically under If-Match. One patch advances the person revision once. Superseding closes world and transaction time without deletion. + operationId: patchPersonStructuredProfile + parameters: + - description: Durable person ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Strong ETag returned by the latest person profile read. Must be the exact single tag from that read; the RFC 7232 forms `*` and comma-separated tag lists are not supported. + in: header + name: If-Match + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PersonProfilePatchRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/StructuredPersonProfile" + description: OK + headers: + ETag: + description: Strong person profile revision tag for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "413": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Atomically patch a person's structured profile + tags: + - API + /api/v1/persons/{id}/profile/history: + get: + description: Returns current and superseded structured values plus source-linked observations for every participant bound to the person. + operationId: getPersonProfileHistory + parameters: + - description: Durable person ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonProfileHistory" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Get a person's structured profile history + tags: + - API + /api/v1/persons/{id}/profile/media/{media_id}/content: + get: + description: Returns the exact inline bytes stored for one media value. URI-only values have no local content and return 404. + operationId: getPersonProfileMediaContent + parameters: + - description: Durable person ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Structured person profile media value ID + in: path + name: media_id + required: true + schema: + format: int64 + type: integer + responses: + "200": + content: + "*/*": + schema: + format: binary + type: string + x-contentMediaType: application/octet-stream + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Download stored inline content for one person profile media value + tags: + - API /api/v1/query: post: operationId: runQuery diff --git a/web/src/lib/api/generated/schema.d.ts b/web/src/lib/api/generated/schema.d.ts index 97142b5cb..bc3d3788d 100644 --- a/web/src/lib/api/generated/schema.d.ts +++ b/web/src/lib/api/generated/schema.d.ts @@ -744,6 +744,30 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/communication-services": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List communication services + * @description Lists the small open service catalog without pagination, including aliases and normalization policy. + */ + get: operations["listCommunicationServices"]; + put?: never; + /** + * Register a communication service + * @description Registers an unknown or custom service without a schema migration. Re-registering a slug is idempotent. + */ + post: operations["createCommunicationService"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/content/remote-image": { parameters: { query?: never; @@ -1474,6 +1498,70 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/persons/{id}/profile": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get a person's current structured profile + * @description Returns only current structured values at one person revision. Superseded values and archive observations are available from the separate history endpoint. + */ + get: operations["getPersonStructuredProfile"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Atomically patch a person's structured profile + * @description Applies up to 200 explicit adds and supersedes atomically under If-Match. One patch advances the person revision once. Superseding closes world and transaction time without deletion. + */ + patch: operations["patchPersonStructuredProfile"]; + trace?: never; + }; + "/api/v1/persons/{id}/profile/history": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get a person's structured profile history + * @description Returns current and superseded structured values plus source-linked observations for every participant bound to the person. + */ + get: operations["getPersonProfileHistory"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/persons/{id}/profile/media/{media_id}/content": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Download stored inline content for one person profile media value + * @description Returns the exact inline bytes stored for one media value. URI-only values have no local content and return 404. + */ + get: operations["getPersonProfileMediaContent"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/query": { parameters: { query?: never; @@ -2546,6 +2634,33 @@ export interface components { } & { [key: string]: unknown; }; + CommunicationService: { + aliases: string[] | null; + /** Format: date-time */ + created_at: string; + default_scope_kind?: string; + display_label: string; + /** Format: int64 */ + id: number; + is_active: boolean; + is_system: boolean; + normalization: string; + /** Format: int64 */ + normalization_version: number; + profile_url_template?: string; + scope_policy: string; + slug: string; + /** Format: date-time */ + updated_at: string; + uri_scheme?: string; + } & { + [key: string]: unknown; + }; + CommunicationServicesResponse: { + services: components["schemas"]["CommunicationService"][] | null; + } & { + [key: string]: unknown; + }; ConversationResponse: { /** Format: int64 */ anchor_id: number; @@ -2578,6 +2693,20 @@ export interface components { value_type: string; vcard_property?: string; }; + CreateCommunicationServiceRequest: { + aliases?: string[] | null; + default_scope_kind?: string; + display_label: string; + /** @enum {string} */ + normalization: "none" | "lower" | "email" | "phone_e164" | "strip_at_lower" | "by_address_kind"; + /** Format: int64 */ + normalization_version?: number; + profile_url_template?: string; + /** @enum {string} */ + scope_policy: "none" | "optional" | "required"; + slug: string; + uri_scheme?: string; + }; CreatePersonRequest: { /** Format: int64 */ participant_id: number; @@ -3395,6 +3524,35 @@ export interface components { } & { [key: string]: unknown; }; + PartialDate: { + /** Format: int64 */ + day?: number; + /** Format: int64 */ + month?: number; + /** Format: int64 */ + year?: number; + }; + ParticipantContactObservation: { + address_kind: string; + envelope: components["schemas"]["ValueEnvelope"]; + normalization: string; + /** Format: int64 */ + normalization_version: number; + normalized_value: string; + /** Format: date-time */ + observed_at?: string; + original_value: string; + /** Format: int64 */ + participant_id: number; + provider_user_id?: string; + scope_kind?: string; + scope_value?: string; + service_slug?: string; + /** Format: int64 */ + source_id?: number; + } & { + [key: string]: unknown; + }; PatchAttributeDefinitionRequest: { description?: string | null; /** Format: int64 */ @@ -3427,6 +3585,52 @@ export interface components { } & { [key: string]: unknown; }; + PersonAddress: { + address_kind: string; + country_code?: string; + country_name?: string; + envelope: components["schemas"]["ValueEnvelope"]; + extended_address?: string; + extended_components?: string; + free_text?: string; + geo_uri?: string; + label?: string; + locality?: string; + original_value: string; + /** Format: int64 */ + person_id: number; + place_uri?: string; + post_office_box?: string; + postal_code?: string; + region?: string; + street_address?: string; + timezone?: string; + } & { + [key: string]: unknown; + }; + PersonAddressInputRequest: { + address_kind: string; + country_code?: string; + country_name?: string; + envelope: components["schemas"]["ValueEnvelopeInput"]; + extended_address?: string; + extended_components?: string; + free_text?: string; + geo_uri?: string; + label?: string; + locality?: string; + original_value?: string; + place_uri?: string; + post_office_box?: string; + postal_code?: string; + region?: string; + street_address?: string; + timezone?: string; + }; + PersonAddressPatchRequest: { + add?: components["schemas"]["PersonAddressInputRequest"][] | null; + supersede?: number[] | null; + }; PersonAttributeGroup: { current: components["schemas"]["PersonAttributeValue"][] | null; definition: components["schemas"]["AttributeDefinition"]; @@ -3475,6 +3679,23 @@ export interface components { } & { [key: string]: unknown; }; + PersonCategory: { + envelope: components["schemas"]["ValueEnvelope"]; + normalized_value: string; + original_value: string; + /** Format: int64 */ + person_id: number; + } & { + [key: string]: unknown; + }; + PersonCategoryInputRequest: { + envelope: components["schemas"]["ValueEnvelopeInput"]; + original_value: string; + }; + PersonCategoryPatchRequest: { + add?: components["schemas"]["PersonCategoryInputRequest"][] | null; + supersede?: number[] | null; + }; PersonCluster: { /** Format: int64 */ canonical_id: number; @@ -3491,6 +3712,36 @@ export interface components { } & { [key: string]: unknown; }; + PersonContactPoint: { + address_kind: string; + envelope: components["schemas"]["ValueEnvelope"]; + normalization: string; + /** Format: int64 */ + normalization_version: number; + normalized_value: string; + original_value: string; + /** Format: int64 */ + person_id: number; + scope_kind?: string; + scope_value?: string; + service_slug?: string; + uri?: string; + } & { + [key: string]: unknown; + }; + PersonContactPointInputRequest: { + address_kind: string; + envelope: components["schemas"]["ValueEnvelopeInput"]; + original_value: string; + scope_kind?: string; + scope_value?: string; + service_slug?: string; + uri?: string; + }; + PersonContactPointPatchRequest: { + add?: components["schemas"]["PersonContactPointInputRequest"][] | null; + supersede?: number[] | null; + }; PersonContextSummaryHTTPResponse: { cache_revision: string; candidate_snapshot_id?: string; @@ -3499,6 +3750,32 @@ export interface components { } & { [key: string]: unknown; }; + PersonDate: { + calendar_scale?: string; + date: components["schemas"]["PartialDate"]; + date_kind: string; + date_text?: string; + envelope: components["schemas"]["ValueEnvelope"]; + label?: string; + original_value: string; + /** Format: int64 */ + person_id: number; + } & { + [key: string]: unknown; + }; + PersonDateInputRequest: { + calendar_scale?: string; + date?: components["schemas"]["PartialDate"]; + date_kind: string; + date_text?: string; + envelope: components["schemas"]["ValueEnvelopeInput"]; + label?: string; + original_value?: string; + }; + PersonDatePatchRequest: { + add?: components["schemas"]["PersonDateInputRequest"][] | null; + supersede?: number[] | null; + }; PersonIdentifier: { display_value?: string; is_primary: boolean; @@ -3510,6 +3787,79 @@ export interface components { } & { [key: string]: unknown; }; + PersonMedia: { + /** Format: int64 */ + byte_size?: number; + content_hash?: string; + envelope: components["schemas"]["ValueEnvelope"]; + has_data: boolean; + media_kind: string; + media_type?: string; + original_value: string; + /** Format: int64 */ + person_id: number; + uri?: string; + } & { + [key: string]: unknown; + }; + PersonMediaInputRequest: { + data?: string; + envelope: components["schemas"]["ValueEnvelopeInput"]; + media_kind: string; + media_type?: string; + original_value?: string; + uri?: string; + }; + PersonMediaPatchRequest: { + add?: components["schemas"]["PersonMediaInputRequest"][] | null; + supersede?: number[] | null; + }; + PersonName: { + additional_names?: string; + envelope: components["schemas"]["ValueEnvelope"]; + family_name?: string; + formatted?: string; + generation?: string; + given_name?: string; + honorific_prefixes?: string; + honorific_suffixes?: string; + is_derived: boolean; + language?: string; + name_kind: string; + original_value: string; + /** Format: int64 */ + person_id: number; + phonetic_script?: string; + phonetic_system?: string; + script?: string; + secondary_surname?: string; + sort_as?: string; + } & { + [key: string]: unknown; + }; + PersonNameInputRequest: { + additional_names?: string; + envelope: components["schemas"]["ValueEnvelopeInput"]; + family_name?: string; + formatted?: string; + generation?: string; + given_name?: string; + honorific_prefixes?: string; + honorific_suffixes?: string; + is_derived?: boolean; + language?: string; + name_kind: string; + original_value?: string; + phonetic_script?: string; + phonetic_system?: string; + script?: string; + secondary_surname?: string; + sort_as?: string; + }; + PersonNamePatchRequest: { + add?: components["schemas"]["PersonNameInputRequest"][] | null; + supersede?: number[] | null; + }; PersonProfile: { display_name?: string; /** Format: int64 */ @@ -3519,6 +3869,26 @@ export interface components { } & { [key: string]: unknown; }; + PersonProfileHistory: { + addresses: components["schemas"]["PersonAddress"][] | null; + categories: components["schemas"]["PersonCategory"][] | null; + contact_points: components["schemas"]["PersonContactPoint"][] | null; + dates: components["schemas"]["PersonDate"][] | null; + media: components["schemas"]["PersonMedia"][] | null; + names: components["schemas"]["PersonName"][] | null; + observations: components["schemas"]["ParticipantContactObservation"][] | null; + person: components["schemas"]["Person"]; + } & { + [key: string]: unknown; + }; + PersonProfilePatchRequest: { + addresses?: components["schemas"]["PersonAddressPatchRequest"]; + categories?: components["schemas"]["PersonCategoryPatchRequest"]; + contact_points?: components["schemas"]["PersonContactPointPatchRequest"]; + dates?: components["schemas"]["PersonDatePatchRequest"]; + media?: components["schemas"]["PersonMediaPatchRequest"]; + names?: components["schemas"]["PersonNamePatchRequest"]; + }; PersonSearchHTTPResponse: { cache_revision: string; candidate_snapshot_id?: string; @@ -4012,6 +4382,17 @@ export interface components { } & { [key: string]: unknown; }; + StructuredPersonProfile: { + addresses: components["schemas"]["PersonAddress"][] | null; + categories: components["schemas"]["PersonCategory"][] | null; + contact_points: components["schemas"]["PersonContactPoint"][] | null; + dates: components["schemas"]["PersonDate"][] | null; + media: components["schemas"]["PersonMedia"][] | null; + names: components["schemas"]["PersonName"][] | null; + person: components["schemas"]["Person"]; + } & { + [key: string]: unknown; + }; Summary: { accounts: string[] | null; date_range: string[] | null; @@ -4239,6 +4620,57 @@ export interface components { } & { [key: string]: unknown; }; + VCardIdentity: { + altid?: string; + group?: string; + pid?: string[] | null; + prop_id?: string; + property?: string; + }; + ValueEnvelope: { + /** Format: date-time */ + active_from?: string; + /** Format: date-time */ + active_until?: string; + /** Format: double */ + confidence?: number; + /** Format: date-time */ + created_at: string; + /** Format: int64 */ + id: number; + /** Format: int64 */ + ordinal: number; + /** Format: int64 */ + pref?: number; + source: string; + source_ref?: string; + /** Format: date-time */ + superseded_at?: string; + type_label?: string; + type_tokens?: string[] | null; + /** Format: date-time */ + updated_at: string; + vcard: components["schemas"]["VCardIdentity"]; + } & { + [key: string]: unknown; + }; + ValueEnvelopeInput: { + /** Format: date-time */ + active_from?: string; + /** Format: date-time */ + active_until?: string; + /** Format: double */ + confidence?: number; + /** Format: int64 */ + ordinal?: number; + /** Format: int64 */ + pref?: number; + source: string; + source_ref?: string; + type_label?: string; + type_tokens?: string[] | null; + vcard?: components["schemas"]["VCardIdentity"]; + }; VectorHealth: { error?: string; status: string; @@ -6708,7 +7140,57 @@ export interface operations { }; }; }; - getRemoteImage: { + listCommunicationServices: { + parameters: { + query?: { + /** @description Include inactive catalog entries */ + include_inactive?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommunicationServicesResponse"]; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + createCommunicationService: { parameters: { query?: never; header?: never; @@ -6717,7 +7199,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["RemoteImageRequest"]; + "application/json": components["schemas"]["CreateCommunicationServiceRequest"]; }; }; responses: { @@ -6727,7 +7209,7 @@ export interface operations { [name: string]: unknown; }; content: { - "image/*": string; + "application/json": components["schemas"]["CommunicationService"]; }; }; /** @description Error */ @@ -6740,7 +7222,7 @@ export interface operations { }; }; /** @description Error */ - 401: { + 404: { headers: { [name: string]: unknown; }; @@ -6749,7 +7231,7 @@ export interface operations { }; }; /** @description Error */ - 415: { + 409: { headers: { [name: string]: unknown; }; @@ -6758,7 +7240,7 @@ export interface operations { }; }; /** @description Error */ - 502: { + 503: { headers: { [name: string]: unknown; }; @@ -6777,18 +7259,87 @@ export interface operations { }; }; }; - getConversation: { + getRemoteImage: { parameters: { - query: { - /** @description Selected message ID anchoring the chronological window */ - anchor: number; - /** @description Messages before the anchor (default 25, max 50) */ - before?: number; - /** @description Messages after the anchor (default 25, max 50) */ - after?: number; - /** @description Lower UTC bound, inclusive (RFC3339). Restricts the window, before/after counts, and has_before/has_after to messages in [start, end) */ - start?: string; - /** @description Upper UTC bound, exclusive (RFC3339). Restricts the window, before/after counts, and has_before/has_after to messages in [start, end) */ + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RemoteImageRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "image/*": string; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getConversation: { + parameters: { + query: { + /** @description Selected message ID anchoring the chronological window */ + anchor: number; + /** @description Messages before the anchor (default 25, max 50) */ + before?: number; + /** @description Messages after the anchor (default 25, max 50) */ + after?: number; + /** @description Lower UTC bound, inclusive (RFC3339). Restricts the window, before/after counts, and has_before/has_after to messages in [start, end) */ + start?: string; + /** @description Upper UTC bound, exclusive (RFC3339). Restricts the window, before/after counts, and has_before/has_after to messages in [start, end) */ end?: string; }; header?: never; @@ -9300,6 +9851,300 @@ export interface operations { }; }; }; + getPersonStructuredProfile: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Durable person ID */ + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + /** @description Strong person profile revision tag for optimistic concurrency */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StructuredPersonProfile"]; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + patchPersonStructuredProfile: { + parameters: { + query?: never; + header: { + /** @description Strong ETag returned by the latest person profile read. Must be the exact single tag from that read; the RFC 7232 forms `*` and comma-separated tag lists are not supported. */ + "If-Match": string; + }; + path: { + /** @description Durable person ID */ + id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PersonProfilePatchRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + /** @description Strong person profile revision tag for optimistic concurrency */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StructuredPersonProfile"]; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getPersonProfileHistory: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Durable person ID */ + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonProfileHistory"]; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getPersonProfileMediaContent: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Durable person ID */ + id: number; + /** @description Structured person profile media value ID */ + media_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; runQuery: { parameters: { query?: never;