From fcd52add071d5bec42d63ba924f3263da8864077 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sun, 9 Aug 2026 08:25:15 -0500 Subject: [PATCH 01/16] feat(store): add structured person profile primitives - fix(store): clean identity matches on source removal - fix(store): preserve observation provenance - fix(store): retire unsupported observation conflicts - fix(store): preserve complete identity evidence - fix(store): reconcile converged identity conflicts - Merge remote-tracking branch origin/main into structured-profile-primitives-v0191 - fix(store): preserve subset identity review data - fix(store): preserve profile lifecycle integrity - fix(client): accept idempotent service registration - fix(store): reconcile serialized source removal - chore(lint): restore CI baseline - fix(api): align profile fallback contract - fix(api): wire structured profile stores in daemon Generated with Codex --- api/openapi.yaml | 1239 ++++++++++++++++- cmd/msgvault/cmd/create_subset.go | 14 +- cmd/msgvault/cmd/serve.go | 40 + cmd/msgvault/cmd/serve_api_wiring_test.go | 38 + cmd/msgvault/cmd/serve_test.go | 5 +- internal/api/attribute_definitions.go | 4 +- internal/api/communication_services.go | 145 ++ internal/api/communication_services_test.go | 72 + internal/api/middleware.go | 7 +- internal/api/openapi.go | 23 +- internal/api/openapi_test.go | 112 +- internal/api/person_profile_values.go | 439 ++++++ internal/api/person_profile_values_test.go | 377 +++++ internal/api/person_profiles.go | 4 +- internal/api/routes.go | 2 + internal/api/saved_views.go | 4 +- internal/api/settings.go | 4 +- internal/store/communication_services.go | 624 +++++++++ internal/store/communication_services_test.go | 197 +++ internal/store/dialect_pg.go | 9 +- internal/store/dialect_sqlite.go | 4 + internal/store/identity_match_candidates.go | 892 ++++++++++++ .../store/identity_match_candidates_test.go | 315 +++++ internal/store/identity_match_merge_test.go | 339 +++++ internal/store/messages.go | 120 +- .../migrate_participant_service_scope.go | 132 ++ ..._participant_service_scope_backend_test.go | 80 ++ .../migrate_participant_service_scope_test.go | 132 ++ internal/store/partialdate.go | 211 +++ internal/store/partialdate_test.go | 148 ++ .../participant_identifier_classification.go | 55 + ...ticipant_identifier_classification_test.go | 126 ++ internal/store/participant_observations.go | 677 +++++++++ .../store/participant_observations_test.go | 566 ++++++++ internal/store/person_addresses.go | 295 ++++ internal/store/person_addresses_test.go | 139 ++ internal/store/person_categories.go | 173 +++ internal/store/person_categories_test.go | 51 + internal/store/person_contact_points.go | 328 +++++ internal/store/person_contact_points_test.go | 123 ++ internal/store/person_dates.go | 211 +++ internal/store/person_dates_test.go | 133 ++ internal/store/person_media.go | 242 ++++ internal/store/person_media_test.go | 105 ++ internal/store/person_names.go | 258 ++++ internal/store/person_names_test.go | 141 ++ internal/store/person_profile.go | 282 ++++ internal/store/person_profile_backend_test.go | 279 ++++ .../store/person_profile_snapshot_pg_test.go | 101 ++ internal/store/person_profile_test.go | 285 ++++ internal/store/persons.go | 15 + .../store/pg_maintenance_internal_test.go | 9 + .../profile_identity_concurrency_pg_test.go | 349 +++++ internal/store/profile_identity_lock.go | 46 + internal/store/profile_store_helpers.go | 152 ++ internal/store/profile_supersede_time_test.go | 248 ++++ internal/store/profile_values.go | 246 ++++ internal/store/profile_values_test.go | 80 ++ internal/store/schema.sql | 438 +++++- internal/store/schema_pg.sql | 438 +++++- internal/store/sources.go | 115 +- internal/store/sources_test.go | 282 ++++ internal/store/store.go | 38 +- internal/store/subset.go | 337 ++++- internal/store/subset_test.go | 269 ++++ pkg/client/client_test.go | 40 + pkg/client/generated/client.go | 390 ++++++ pkg/client/generated/client_options.go | 282 ++++ pkg/client/generated/client_with_response.go | 644 +++++++++ pkg/client/generated/enums.go | 39 + pkg/client/generated/headers.go | 9 + pkg/client/generated/paths.go | 23 + pkg/client/generated/payloads.go | 4 + pkg/client/generated/queries.go | 5 + pkg/client/generated/responses.go | 132 ++ pkg/client/generated/types.go | 914 ++++++++++++ pkg/client/openapi.yaml | 1229 +++++++++++++++- web/src/lib/api/generated/schema.d.ts | 879 +++++++++++- 78 files changed, 17828 insertions(+), 126 deletions(-) create mode 100644 internal/api/communication_services.go create mode 100644 internal/api/communication_services_test.go create mode 100644 internal/api/person_profile_values.go create mode 100644 internal/api/person_profile_values_test.go create mode 100644 internal/store/communication_services.go create mode 100644 internal/store/communication_services_test.go create mode 100644 internal/store/identity_match_candidates.go create mode 100644 internal/store/identity_match_candidates_test.go create mode 100644 internal/store/identity_match_merge_test.go create mode 100644 internal/store/migrate_participant_service_scope.go create mode 100644 internal/store/migrate_participant_service_scope_backend_test.go create mode 100644 internal/store/migrate_participant_service_scope_test.go create mode 100644 internal/store/partialdate.go create mode 100644 internal/store/partialdate_test.go create mode 100644 internal/store/participant_identifier_classification.go create mode 100644 internal/store/participant_identifier_classification_test.go create mode 100644 internal/store/participant_observations.go create mode 100644 internal/store/participant_observations_test.go create mode 100644 internal/store/person_addresses.go create mode 100644 internal/store/person_addresses_test.go create mode 100644 internal/store/person_categories.go create mode 100644 internal/store/person_categories_test.go create mode 100644 internal/store/person_contact_points.go create mode 100644 internal/store/person_contact_points_test.go create mode 100644 internal/store/person_dates.go create mode 100644 internal/store/person_dates_test.go create mode 100644 internal/store/person_media.go create mode 100644 internal/store/person_media_test.go create mode 100644 internal/store/person_names.go create mode 100644 internal/store/person_names_test.go create mode 100644 internal/store/person_profile.go create mode 100644 internal/store/person_profile_backend_test.go create mode 100644 internal/store/person_profile_snapshot_pg_test.go create mode 100644 internal/store/person_profile_test.go create mode 100644 internal/store/profile_identity_concurrency_pg_test.go create mode 100644 internal/store/profile_identity_lock.go create mode 100644 internal/store/profile_store_helpers.go create mode 100644 internal/store/profile_supersede_time_test.go create mode 100644 internal/store/profile_values.go create mode 100644 internal/store/profile_values_test.go 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/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/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..a7ca1e38d --- /dev/null +++ b/internal/api/person_profile_values.go @@ -0,0 +1,439 @@ +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.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/store/communication_services.go b/internal/store/communication_services.go new file mode 100644 index 000000000..aad5bdd73 --- /dev/null +++ b/internal/store/communication_services.go @@ -0,0 +1,624 @@ +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") + 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") + 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 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 { + if service == nil { + return nil + } + hasKind := scopeKind != nil && strings.TrimSpace(*scopeKind) != "" + hasValue := scopeValue != nil && strings.TrimSpace(*scopeValue) != "" + switch service.ScopePolicy { + case ScopePolicyRequired: + if !hasKind || !hasValue { + return ErrServiceScopeRequired + } + case ScopePolicyNone: + if hasKind || hasValue { + return ErrServiceScopeForbidden + } + } + return nil +} + +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..78a6244b9 --- /dev/null +++ b/internal/store/communication_services_test.go @@ -0,0 +1,197 @@ +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 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, + ) +} diff --git a/internal/store/dialect_pg.go b/internal/store/dialect_pg.go index a17d7e714..6aba2cdd7 100644 --- a/internal/store/dialect_pg.go +++ b/internal/store/dialect_pg.go @@ -599,6 +599,10 @@ 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"}, // 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 +879,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 +902,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..1b77ac5e9 100644 --- a/internal/store/dialect_sqlite.go +++ b/internal/store/dialect_sqlite.go @@ -775,6 +775,10 @@ 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"}, // 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..3c8e0b601 --- /dev/null +++ b/internal/store/identity_match_candidates.go @@ -0,0 +1,892 @@ +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 + } + service, hasService, err := s.resolveOptionalCommunicationServiceContext(ctx, input.ServiceSlug) + if 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 + } + if state == IdentityMatchStateAccepted && + current.Basis != IdentityMatchStableProviderID && + decidedBy != "user" { + return ErrIdentityMatchNotAcceptable + } + if _, err := tx.ExecContext(ctx, `UPDATE identity_match_candidates SET + state = ?, decided_by = ?, decided_at = `+s.dialect.Now()+`, + notes = ?, updated_at = `+s.dialect.Now()+` WHERE id = ?`, + state, decidedBy, stringValue(notes), 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 + 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, 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.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) + + 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 = ?, 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, + decidedBy, decidedAt, notes, winner.ID, + ); err != nil { + return fmt.Errorf("reconcile duplicate identity match candidate: %w", err) + } + return nil +} + +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 && + candidate.ObservationConflictOrigin.Valid { + 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 && + (candidate.DecidedBy.Valid || candidate.DecidedAt.Valid || candidate.Notes.Valid) { + return state, candidate.DecidedBy, candidate.DecidedAt, candidate.Notes + } + } + return state, sql.NullString{}, sql.NullTime{}, sql.NullString{} +} + +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..1f6881ca6 --- /dev/null +++ b/internal/store/identity_match_candidates_test.go @@ -0,0 +1,315 @@ +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, 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 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..0df8f8a4e --- /dev/null +++ b/internal/store/identity_match_merge_test.go @@ -0,0 +1,339 @@ +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 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..8e21fce2d 100644 --- a/internal/store/messages.go +++ b/internal/store/messages.go @@ -2730,10 +2730,25 @@ func (s *Store) EnsureParticipantByPhone(phone, displayName, identifierType stri return 0, fmt.Errorf("upsert participant by phone: %w", err) } - // 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. + serviceSlug, scopeKind, scopeValue := participantIdentifierClassificationValues( + identifierType, phone, + ) + _, err = s.db.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`, + id, identifierType, phone, serviceSlug, scopeKind, scopeValue) if err != nil { return 0, fmt.Errorf("insert participant identifier: %w", err) } @@ -2756,6 +2771,11 @@ func (s *Store) MergeParticipants(oldID, newID int64) error { if err := s.lockIdentityMutationTx(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 +2867,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 +2909,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 +2980,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 +3047,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( @@ -3032,11 +3120,17 @@ func (s *Store) EnsureParticipantByIdentifier(identifierType, identifierValue, d if err != nil { return 0, fmt.Errorf("insert participant: %w", err) } + serviceSlug, scopeKind, scopeValue := participantIdentifierClassificationValues( + identifierType, identifierValue, + ) _, err = s.db.Exec(` INSERT INTO participant_identifiers ( - participant_id, identifier_type, identifier_value, display_value, is_primary - ) VALUES (?, ?, ?, ?, TRUE) - `, participantID, identifierType, identifierValue, identifierValue) + 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 0, fmt.Errorf("insert participant identifier: %w", err) } 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..45945d112 --- /dev/null +++ b/internal/store/migrate_participant_service_scope_backend_test.go @@ -0,0 +1,80 @@ +package store_test + +import ( + "context" + "database/sql" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/testutil/storetest" +) + +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) + + 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(err) + } else { + 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(err) + } + } + + _, 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") +} 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_identifier_classification.go b/internal/store/participant_identifier_classification.go new file mode 100644 index 000000000..242d3e452 --- /dev/null +++ b/internal/store/participant_identifier_classification.go @@ -0,0 +1,55 @@ +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:]) + } + 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]) + } + 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..54e7b97d1 --- /dev/null +++ b/internal/store/participant_identifier_classification_test.go @@ -0,0 +1,126 @@ +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"}, + {"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_observations.go b/internal/store/participant_observations.go new file mode 100644 index 000000000..4efeccb2e --- /dev/null +++ b/internal/store/participant_observations.go @@ -0,0 +1,677 @@ +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 + } + 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, + ) + if err == nil { + if observation.ProviderUserID == nil && input.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 + return nil + } + if !errors.Is(err, ErrProfileValueNotFound) { + 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(input.Envelope.valueEnvelope(0))...) + 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 + } + + 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 +} + +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 + } + 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 demotes promoted candidates 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 `+identityCandidateObservationProviderConflictSQL( + "current_left", "current_right", + )+` + ) + ) + ) + UPDATE identity_match_candidates + SET state = 'candidate', observation_conflict_origin = 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 `+identityCandidateObservationProviderConflictSQL( + "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 +} + +func identityCandidateObservationProviderConflictSQL(leftAlias, rightAlias string) string { + return `(` + 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..e20426028 --- /dev/null +++ b/internal/store/participant_observations_test.go @@ -0,0 +1,566 @@ +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 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 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 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 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..6e9f187d7 --- /dev/null +++ b/internal/store/person_addresses.go @@ -0,0 +1,295 @@ +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, + } { + 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, + } { + 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..cdf7fd8da --- /dev/null +++ b/internal/store/person_addresses_test.go @@ -0,0 +1,139 @@ +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) + }{ + { + 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 + }, + }, + } { + 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) + + address, err := st.AddPersonAddressContext( + t.Context(), newTestPerson(t, st), input, + ) + require.NoError(err) + require.Equal(test.value, address.OriginalValue) + }) + } +} + +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) + 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..81e90e439 --- /dev/null +++ b/internal/store/person_contact_points.go @@ -0,0 +1,328 @@ +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 + } + 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 + } + 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..8f9847b48 --- /dev/null +++ b/internal/store/person_profile_backend_test.go @@ -0,0 +1,279 @@ +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", + "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() + + _, err := st.DB().ExecContext( + ctx, "ALTER TABLE identity_match_candidates DROP COLUMN observation_conflict_origin", + ) + require.NoError(err) + require.NoError(st.InitSchemaContext(ctx)) + + assert.Contains( + liveTableColumns(t, st, "identity_match_candidates"), + "observation_conflict_origin", + ) +} + +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_store_helpers.go b/internal/store/profile_store_helpers.go new file mode 100644 index 000000000..a1dcb588c --- /dev/null +++ b/internal/store/profile_store_helpers.go @@ -0,0 +1,152 @@ +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 nextProfileOrdinalTx( + ctx context.Context, + tx *loggedTx, + table, kindColumn string, + personID int64, + kind any, +) (int, error) { + var ordinal int + query := fmt.Sprintf(`SELECT COALESCE(MAX(ordinal) + 1, 0) + FROM %s WHERE person_id = ?`, table) + args := []any{personID} + if kindColumn != "" { + query += fmt.Sprintf(` AND %s = ?`, kindColumn) + args = append(args, kind) + } + query += ` AND active_until IS NULL AND superseded_at IS NULL` + 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 resolveProfileEnvelopeTx( + ctx context.Context, + tx *loggedTx, + table, kindColumn string, + personID 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 := nextProfileOrdinalTx(ctx, tx, table, kindColumn, personID, kind) + if err != nil { + return ValueEnvelope{}, err + } + return input.valueEnvelope(ordinal), nil +} + +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..e22e1f084 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,409 @@ 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') + ), + 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..833f83a29 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,409 @@ 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', + confidence DOUBLE PRECISION 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') + ), + 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..d52826568 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 `+identityCandidateObservationProviderConflictSQL( + "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; From 091ff0d7275520e61cab5db62d39d6d20d589329 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Sun, 9 Aug 2026 09:13:48 -0500 Subject: [PATCH 02/16] fix(store): harden profile identity primitives against data loss Address three review findings on the structured profile primitives: - A contradictory non-null provider user ID for an existing current observation now supersedes the row and records the new binding as a fresh observation, instead of being silently dropped. Generated conflicts left unsupported by the provider change are cleaned up. - System acceptance of an identity match candidate now requires the candidate to record which stable provider ID matched (non-null normalized_value); the caller-supplied basis label alone no longer suffices. - ValidateServiceScope rejects half-scopes (scope kind without value or the reverse) for optional-scope services and serviceless entries, and scope inputs are trimmed with blanks treated as absent, so malformed scopes cannot fragment identity keys. New ErrServiceScopeIncomplete maps to a 400 in the person profile API. Co-Authored-By: Claude Fable 5 --- internal/api/person_profile_values.go | 3 +- internal/store/communication_services.go | 58 +++++++++----- internal/store/communication_services_test.go | 55 ++++++++++++++ internal/store/identity_match_candidates.go | 11 ++- .../store/identity_match_candidates_test.go | 31 +++++++- internal/store/participant_observations.go | 55 ++++++++++++-- .../store/participant_observations_test.go | 76 +++++++++++++++++++ internal/store/person_contact_points.go | 4 + 8 files changed, 263 insertions(+), 30 deletions(-) diff --git a/internal/api/person_profile_values.go b/internal/api/person_profile_values.go index a7ca1e38d..698119010 100644 --- a/internal/api/person_profile_values.go +++ b/internal/api/person_profile_values.go @@ -377,7 +377,8 @@ func isPersonProfileValidationError(err error) bool { store.ErrPersonCategoryEmpty, store.ErrInvalidPersonMediaKind, store.ErrPersonMediaEmpty, store.ErrPersonMediaTooLarge, store.ErrServiceNotFound, store.ErrServiceScopeRequired, - store.ErrServiceScopeForbidden, store.ErrNormalizationRejected, + store.ErrServiceScopeForbidden, store.ErrServiceScopeIncomplete, + store.ErrNormalizationRejected, store.ErrPersonProfilePatchEmpty, store.ErrProfileValueCloseBeforeActive, } { if errors.Is(err, target) { diff --git a/internal/store/communication_services.go b/internal/store/communication_services.go index aad5bdd73..5203f21a8 100644 --- a/internal/store/communication_services.go +++ b/internal/store/communication_services.go @@ -31,15 +31,16 @@ const ( ) 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") - 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") - ErrNormalizationRejected = errors.New("value cannot be normalized for this service") + 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") + 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 { @@ -345,24 +346,41 @@ func NormalizeServiceValue(service *CommunicationService, addressKind ContactAdd } func ValidateServiceScope(service *CommunicationService, scopeKind, scopeValue *string) error { - if service == nil { - return nil - } hasKind := scopeKind != nil && strings.TrimSpace(*scopeKind) != "" hasValue := scopeValue != nil && strings.TrimSpace(*scopeValue) != "" - switch service.ScopePolicy { - case ScopePolicyRequired: - if !hasKind || !hasValue { - return ErrServiceScopeRequired - } - case ScopePolicyNone: - if hasKind || hasValue { - return ErrServiceScopeForbidden + 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 } +// normalizeScopeInput trims a scope kind or value and treats blank input as +// absent, so blank-vs-NULL and padded variants cannot fragment identity keys. +func normalizeScopeInput(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 diff --git a/internal/store/communication_services_test.go b/internal/store/communication_services_test.go index 78a6244b9..be33468f7 100644 --- a/internal/store/communication_services_test.go +++ b/internal/store/communication_services_test.go @@ -195,3 +195,58 @@ func TestValidateServiceScopeFollowsScopePolicy(t *testing.T) { 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/identity_match_candidates.go b/internal/store/identity_match_candidates.go index 3c8e0b601..8e48a267d 100644 --- a/internal/store/identity_match_candidates.go +++ b/internal/store/identity_match_candidates.go @@ -169,6 +169,8 @@ func (s *Store) UpsertIdentityMatchCandidateContext( if err != nil { return nil, false, err } + input.ScopeKind = normalizeScopeInput(input.ScopeKind) + input.ScopeValue = normalizeScopeInput(input.ScopeValue) service, hasService, err := s.resolveOptionalCommunicationServiceContext(ctx, input.ServiceSlug) if err != nil { return nil, false, err @@ -413,9 +415,12 @@ func (s *Store) DecideIdentityMatchCandidateContext( if err != nil { return err } - if state == IdentityMatchStateAccepted && - current.Basis != IdentityMatchStableProviderID && - decidedBy != "user" { + // 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) { return ErrIdentityMatchNotAcceptable } if _, err := tx.ExecContext(ctx, `UPDATE identity_match_candidates SET diff --git a/internal/store/identity_match_candidates_test.go b/internal/store/identity_match_candidates_test.go index 1f6881ca6..7ae0573d2 100644 --- a/internal/store/identity_match_candidates_test.go +++ b/internal/store/identity_match_candidates_test.go @@ -57,7 +57,8 @@ func TestStableProviderIDCandidateMayBeAcceptedBySystem(t *testing.T) { candidate, _, err := st.UpsertIdentityMatchCandidateContext(context.Background(), store.IdentityMatchCandidateInput{ LeftKind: store.IdentityMatchParticipant, LeftID: left, RightKind: store.IdentityMatchParticipant, RightID: right, - Basis: store.IdentityMatchStableProviderID, State: store.IdentityMatchStateCandidate, + Basis: store.IdentityMatchStableProviderID, NormalizedValue: new("beeper-user-1"), + State: store.IdentityMatchStateCandidate, Source: store.ProvenanceArchiveObservation, }) require.NoError(err) @@ -68,6 +69,34 @@ func TestStableProviderIDCandidateMayBeAcceptedBySystem(t *testing.T) { 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 TestUpsertIdentityMatchCandidateRejectsDecisionStates(t *testing.T) { require := require.New(t) st := storetest.New(t).Store diff --git a/internal/store/participant_observations.go b/internal/store/participant_observations.go index 4efeccb2e..668519bb2 100644 --- a/internal/store/participant_observations.go +++ b/internal/store/participant_observations.go @@ -155,6 +155,8 @@ func (s *Store) RecordContactObservationContext( if strings.TrimSpace(input.OriginalValue) == "" { return nil, ErrObservationValueMissing } + input.ScopeKind = normalizeScopeInput(input.ScopeKind) + input.ScopeValue = normalizeScopeInput(input.ScopeValue) service, hasService, err := s.resolveOptionalCommunicationServiceContext(ctx, input.ServiceSlug) if err != nil { return nil, err @@ -224,8 +226,16 @@ func (s *Store) RecordContactObservationContext( ctx, tx, participantID, input.SourceID, input.AddressKind, serviceID, input.ScopeKind, input.ScopeValue, normalized, ) + providerContradicted := false if err == nil { - if observation.ProviderUserID == nil && input.ProviderUserID != 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()+` @@ -245,11 +255,19 @@ func (s *Store) RecordContactObservationContext( ); err != nil { return err } + result.Observation = observation + return nil } - result.Observation = observation - return nil - } - if !errors.Is(err, ErrProfileValueNotFound) { + // 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 } args := []any{ @@ -280,6 +298,15 @@ func (s *Store) RecordContactObservationContext( 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, @@ -322,6 +349,22 @@ func (s *Store) RecordContactObservationContext( 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) { @@ -351,6 +394,8 @@ func (s *Store) FindObservationsByAddressContext( if !query.AddressKind.Valid() { return nil, ErrInvalidContactAddressKind } + query.ScopeKind = normalizeScopeInput(query.ScopeKind) + query.ScopeValue = normalizeScopeInput(query.ScopeValue) service, hasService, err := s.resolveOptionalCommunicationServiceContext(ctx, query.ServiceSlug) if err != nil { return nil, err diff --git a/internal/store/participant_observations_test.go b/internal/store/participant_observations_test.go index e20426028..f1ed0d39d 100644 --- a/internal/store/participant_observations_test.go +++ b/internal/store/participant_observations_test.go @@ -302,6 +302,82 @@ func TestProviderIDEnrichmentRemovesGeneratedConflict(t *testing.T) { 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) diff --git a/internal/store/person_contact_points.go b/internal/store/person_contact_points.go index 81e90e439..8e4c69b65 100644 --- a/internal/store/person_contact_points.go +++ b/internal/store/person_contact_points.go @@ -88,6 +88,8 @@ func (s *Store) FindPersonContactPointsContext( if !query.AddressKind.Valid() { return nil, ErrInvalidContactAddressKind } + query.ScopeKind = normalizeScopeInput(query.ScopeKind) + query.ScopeValue = normalizeScopeInput(query.ScopeValue) service, hasService, err := s.resolveOptionalCommunicationServiceContext(ctx, query.ServiceSlug) if err != nil { return nil, err @@ -156,6 +158,8 @@ func (s *Store) addPersonContactPointTx( if strings.TrimSpace(input.OriginalValue) == "" { return nil, ErrContactPointValueMissing } + input.ScopeKind = normalizeScopeInput(input.ScopeKind) + input.ScopeValue = normalizeScopeInput(input.ScopeValue) service, hasService, err := resolveCommunicationServiceTx(ctx, tx, input.ServiceSlug) if err != nil { return nil, err From 474857d76ccf7e992668890b607f3215360fde45 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Sun, 9 Aug 2026 12:28:25 -0500 Subject: [PATCH 03/16] fix(store): keep decision metadata when merge collapse yields conflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a participant merge collapses a user-decided candidate with an observation-generated conflict for the same edge, the conflict state wins but the decision's decided_by, decided_at, and notes were copied only from candidates already in the conflict state — so the review history vanished, and a later conflict cleanup could demote the merged row to an undecided candidate with no trace of the decision. Fall back to the terminal decision's metadata (then any reviewed candidate's) when no conflict-state row carries any. Co-Authored-By: Claude Fable 5 --- internal/store/identity_match_candidates.go | 23 ++++++++- internal/store/identity_match_merge_test.go | 53 +++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/internal/store/identity_match_candidates.go b/internal/store/identity_match_candidates.go index 8e48a267d..d6bd9abb1 100644 --- a/internal/store/identity_match_candidates.go +++ b/internal/store/identity_match_candidates.go @@ -686,14 +686,33 @@ func reconcileIdentityMatchCandidateMergeState( } } for _, candidate := range group { - if candidate.State == state && - (candidate.DecidedBy.Valid || candidate.DecidedAt.Valid || candidate.Notes.Valid) { + 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) { diff --git a/internal/store/identity_match_merge_test.go b/internal/store/identity_match_merge_test.go index 0df8f8a4e..585e87a80 100644 --- a/internal/store/identity_match_merge_test.go +++ b/internal/store/identity_match_merge_test.go @@ -246,6 +246,59 @@ func TestMergeParticipantsPreservesPromotedObservationConflictOrigin(t *testing. 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) +} + func TestMergeParticipantsKeepsCandidatesForDistinctNormalizedValues(t *testing.T) { require := require.New(t) assert := assert.New(t) From a57089fcd8c890e55332151b62a590cfb23e646c Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Sun, 9 Aug 2026 13:07:27 -0500 Subject: [PATCH 04/16] fix(store): restore pre-conflict decisions and drop cross-kind support Two identity-candidate hardening changes: - Conflict cleanup restores a collapsed terminal decision instead of demoting to an undecided candidate. A new nullable identity_match_candidates.pre_conflict_state column records the state a conflict should return to when its observation support disappears; participant merges set it when a single accepted or rejected decision loses to a conflict, opposing decisions leave it unset, and a fresh user decision clears it. Legacy databases gain the column via the existing ADD COLUMN migration list on both dialects. - Conflict support predicates now require the observation pair to share one address kind, matching generation (which only ever pairs same-kind observations). Previously a username conflict could stay alive indefinitely because a cross-kind pair (say username vs social) with the same normalized value still counted as support. Co-Authored-By: Claude Fable 5 --- internal/store/dialect_pg.go | 1 + internal/store/dialect_sqlite.go | 1 + internal/store/identity_match_candidates.go | 47 +++++++++++++++++-- internal/store/identity_match_merge_test.go | 19 ++++++++ internal/store/participant_observations.go | 23 +++++---- .../store/participant_observations_test.go | 36 ++++++++++++++ internal/store/person_profile_backend_test.go | 18 +++---- internal/store/schema.sql | 3 ++ internal/store/schema_pg.sql | 3 ++ internal/store/sources.go | 2 +- 10 files changed, 131 insertions(+), 22 deletions(-) diff --git a/internal/store/dialect_pg.go b/internal/store/dialect_pg.go index 6aba2cdd7..180094a4e 100644 --- a/internal/store/dialect_pg.go +++ b/internal/store/dialect_pg.go @@ -603,6 +603,7 @@ func (d *PostgreSQLDialect) LegacyColumnMigrations() []ColumnMigration { {`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 diff --git a/internal/store/dialect_sqlite.go b/internal/store/dialect_sqlite.go index 1b77ac5e9..0db176c53 100644 --- a/internal/store/dialect_sqlite.go +++ b/internal/store/dialect_sqlite.go @@ -779,6 +779,7 @@ func (d *SQLiteDialect) LegacyColumnMigrations() []ColumnMigration { {`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 index d6bd9abb1..e87eb2592 100644 --- a/internal/store/identity_match_candidates.go +++ b/internal/store/identity_match_candidates.go @@ -425,7 +425,8 @@ func (s *Store) DecideIdentityMatchCandidateContext( } if _, err := tx.ExecContext(ctx, `UPDATE identity_match_candidates SET state = ?, decided_by = ?, decided_at = `+s.dialect.Now()+`, - notes = ?, updated_at = `+s.dialect.Now()+` WHERE id = ?`, + notes = ?, pre_conflict_state = NULL, + updated_at = `+s.dialect.Now()+` WHERE id = ?`, state, decidedBy, stringValue(notes), candidateID, ); err != nil { return fmt.Errorf("decide identity match candidate: %w", err) @@ -452,6 +453,7 @@ type identityMatchCandidateMergeRow struct { Source Provenance SourceRef sql.NullString ObservationConflictOrigin sql.NullString + PreConflictState sql.NullString DecidedBy sql.NullString DecidedAt sql.NullTime Notes sql.NullString @@ -460,7 +462,7 @@ type identityMatchCandidateMergeRow struct { 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, decided_by, decided_at, notes + observation_conflict_origin, pre_conflict_state, decided_by, decided_at, notes FROM identity_match_candidates` func scanIdentityMatchCandidateMergeRow(row scanner) (identityMatchCandidateMergeRow, error) { @@ -472,7 +474,7 @@ func scanIdentityMatchCandidateMergeRow(row scanner) (identityMatchCandidateMerg &candidate.NormalizedValue, &candidate.State, &candidate.Confidence, &candidate.Source, &candidate.SourceRef, &candidate.ObservationConflictOrigin, - &candidate.DecidedBy, + &candidate.PreConflictState, &candidate.DecidedBy, &candidate.DecidedAt, &candidate.Notes, ) return candidate, err @@ -594,6 +596,7 @@ func (s *Store) collapseIdentityMatchCandidateMergeGroupTx( 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 @@ -610,10 +613,11 @@ func (s *Store) collapseIdentityMatchCandidateMergeGroupTx( 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 = ?, decided_by = ?, decided_at = ?, notes = ?, + 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, + confidence, source, sourceRef, observationOrigin, preConflict, decidedBy, decidedAt, notes, winner.ID, ); err != nil { return fmt.Errorf("reconcile duplicate identity match candidate: %w", err) @@ -621,6 +625,39 @@ func (s *Store) collapseIdentityMatchCandidateMergeGroupTx( 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, diff --git a/internal/store/identity_match_merge_test.go b/internal/store/identity_match_merge_test.go index 585e87a80..c01e12bb9 100644 --- a/internal/store/identity_match_merge_test.go +++ b/internal/store/identity_match_merge_test.go @@ -297,6 +297,25 @@ func TestMergeParticipantsPreservesDecisionMetadataWhenConflictWins(t *testing.T 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 TestMergeParticipantsKeepsCandidatesForDistinctNormalizedValues(t *testing.T) { diff --git a/internal/store/participant_observations.go b/internal/store/participant_observations.go index 668519bb2..cbc13a5a7 100644 --- a/internal/store/participant_observations.go +++ b/internal/store/participant_observations.go @@ -467,9 +467,10 @@ func (s *Store) SupersedeParticipantObservationContext( } // deleteUnsupportedObservationIdentityConflictsContext removes generated -// conflicts and demotes promoted candidates 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. +// 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 { @@ -491,14 +492,15 @@ func (s *Store) deleteUnsupportedObservationIdentityConflictsContext( SELECT 1 FROM participant_contact_observations current_right WHERE current_right.participant_id = c.right_id AND `+identityCandidateObservationMatchSQL("current_right")+` - AND `+identityCandidateObservationProviderConflictSQL( + AND `+identityCandidateObservationPairSQL( "current_left", "current_right", )+` ) ) ) UPDATE identity_match_candidates - SET state = 'candidate', observation_conflict_origin = NULL, + 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) @@ -521,7 +523,7 @@ func (s *Store) deleteUnsupportedObservationIdentityConflictsContext( SELECT 1 FROM participant_contact_observations current_right WHERE current_right.participant_id = c.right_id AND `+identityCandidateObservationMatchSQL("current_right")+` - AND `+identityCandidateObservationProviderConflictSQL( + AND `+identityCandidateObservationPairSQL( "current_left", "current_right", )+` ) @@ -534,8 +536,13 @@ func (s *Store) deleteUnsupportedObservationIdentityConflictsContext( return nil } -func identityCandidateObservationProviderConflictSQL(leftAlias, rightAlias string) string { - return `(` + leftAlias + `.provider_user_id IS NULL +// 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)` } diff --git a/internal/store/participant_observations_test.go b/internal/store/participant_observations_test.go index f1ed0d39d..df54eb395 100644 --- a/internal/store/participant_observations_test.go +++ b/internal/store/participant_observations_test.go @@ -414,6 +414,42 @@ func TestMergeParticipantsRemovesConflictAfterProviderIDConvergence(t *testing.T 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) diff --git a/internal/store/person_profile_backend_test.go b/internal/store/person_profile_backend_test.go index 8f9847b48..a04c1cc4c 100644 --- a/internal/store/person_profile_backend_test.go +++ b/internal/store/person_profile_backend_test.go @@ -106,6 +106,7 @@ func TestProfileTableColumnsMatchOnTheConfiguredBackend(t *testing.T) { "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", }, }, @@ -144,16 +145,17 @@ func TestInitSchemaAddsObservationConflictOriginToExistingCandidateTable(t *test st := storetest.New(t).Store ctx := t.Context() - _, err := st.DB().ExecContext( - ctx, "ALTER TABLE identity_match_candidates DROP COLUMN observation_conflict_origin", - ) - require.NoError(err) + 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)) - assert.Contains( - liveTableColumns(t, st, "identity_match_candidates"), - "observation_conflict_origin", - ) + columns := liveTableColumns(t, st, "identity_match_candidates") + assert.Contains(columns, "observation_conflict_origin") + assert.Contains(columns, "pre_conflict_state") } func TestProfileReadsSucceedOnTheConfiguredBackend(t *testing.T) { diff --git a/internal/store/schema.sql b/internal/store/schema.sql index e22e1f084..e7f306cf4 100644 --- a/internal/store/schema.sql +++ b/internal/store/schema.sql @@ -1180,6 +1180,9 @@ CREATE TABLE IF NOT EXISTS identity_match_candidates ( 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, diff --git a/internal/store/schema_pg.sql b/internal/store/schema_pg.sql index 833f83a29..404a637d8 100644 --- a/internal/store/schema_pg.sql +++ b/internal/store/schema_pg.sql @@ -960,6 +960,9 @@ CREATE TABLE IF NOT EXISTS identity_match_candidates ( 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, diff --git a/internal/store/sources.go b/internal/store/sources.go index d52826568..3f008c44e 100644 --- a/internal/store/sources.go +++ b/internal/store/sources.go @@ -428,7 +428,7 @@ func (s *Store) deleteSourceObservationIdentityCandidatesContext( 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 `+identityCandidateObservationProviderConflictSQL( + AND `+identityCandidateObservationPairSQL( "kept_left", "kept_right", )+` ) From f045c196ef1ecda46d4f397b01f8c6266e3038f1 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Sun, 9 Aug 2026 13:53:22 -0500 Subject: [PATCH 05/16] fix(store): validate scope and blank stable IDs on candidate upserts Candidate upserts now call ValidateServiceScope after resolving the service, so a required-scope service rejects unscoped candidates and half-scopes are refused, matching observation and contact-point writes. Blank normalized values are stored as absent rather than as empty strings, and the non-user acceptance guard rejects a blank stable provider ID. The shared trim helper is renamed trimmedOrNil now that it covers normalized values as well as scope parts. Co-Authored-By: Claude Fable 5 --- internal/store/communication_services.go | 7 +-- internal/store/identity_match_candidates.go | 11 ++-- .../store/identity_match_candidates_test.go | 52 +++++++++++++++++++ internal/store/participant_observations.go | 8 +-- internal/store/person_contact_points.go | 8 +-- 5 files changed, 72 insertions(+), 14 deletions(-) diff --git a/internal/store/communication_services.go b/internal/store/communication_services.go index 5203f21a8..a40298b06 100644 --- a/internal/store/communication_services.go +++ b/internal/store/communication_services.go @@ -368,9 +368,10 @@ func ValidateServiceScope(service *CommunicationService, scopeKind, scopeValue * return nil } -// normalizeScopeInput trims a scope kind or value and treats blank input as -// absent, so blank-vs-NULL and padded variants cannot fragment identity keys. -func normalizeScopeInput(value *string) *string { +// trimmedOrNil trims an optional identity-key part (scope kind, scope value, +// normalized value) 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 } diff --git a/internal/store/identity_match_candidates.go b/internal/store/identity_match_candidates.go index e87eb2592..11dda7328 100644 --- a/internal/store/identity_match_candidates.go +++ b/internal/store/identity_match_candidates.go @@ -169,12 +169,16 @@ func (s *Store) UpsertIdentityMatchCandidateContext( if err != nil { return nil, false, err } - input.ScopeKind = normalizeScopeInput(input.ScopeKind) - input.ScopeValue = normalizeScopeInput(input.ScopeValue) + 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 @@ -420,7 +424,8 @@ func (s *Store) DecideIdentityMatchCandidateContext( // label alone is caller-supplied and proves nothing. if state == IdentityMatchStateAccepted && decidedBy != "user" && (current.Basis != IdentityMatchStableProviderID || - current.NormalizedValue == nil) { + current.NormalizedValue == nil || + strings.TrimSpace(*current.NormalizedValue) == "") { return ErrIdentityMatchNotAcceptable } if _, err := tx.ExecContext(ctx, `UPDATE identity_match_candidates SET diff --git a/internal/store/identity_match_candidates_test.go b/internal/store/identity_match_candidates_test.go index 7ae0573d2..b3e585040 100644 --- a/internal/store/identity_match_candidates_test.go +++ b/internal/store/identity_match_candidates_test.go @@ -97,6 +97,58 @@ func TestStableProviderIDCandidateWithoutRecordedValueRequiresUserAcceptance(t * 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 diff --git a/internal/store/participant_observations.go b/internal/store/participant_observations.go index cbc13a5a7..1e858b4e4 100644 --- a/internal/store/participant_observations.go +++ b/internal/store/participant_observations.go @@ -155,8 +155,8 @@ func (s *Store) RecordContactObservationContext( if strings.TrimSpace(input.OriginalValue) == "" { return nil, ErrObservationValueMissing } - input.ScopeKind = normalizeScopeInput(input.ScopeKind) - input.ScopeValue = normalizeScopeInput(input.ScopeValue) + input.ScopeKind = trimmedOrNil(input.ScopeKind) + input.ScopeValue = trimmedOrNil(input.ScopeValue) service, hasService, err := s.resolveOptionalCommunicationServiceContext(ctx, input.ServiceSlug) if err != nil { return nil, err @@ -394,8 +394,8 @@ func (s *Store) FindObservationsByAddressContext( if !query.AddressKind.Valid() { return nil, ErrInvalidContactAddressKind } - query.ScopeKind = normalizeScopeInput(query.ScopeKind) - query.ScopeValue = normalizeScopeInput(query.ScopeValue) + query.ScopeKind = trimmedOrNil(query.ScopeKind) + query.ScopeValue = trimmedOrNil(query.ScopeValue) service, hasService, err := s.resolveOptionalCommunicationServiceContext(ctx, query.ServiceSlug) if err != nil { return nil, err diff --git a/internal/store/person_contact_points.go b/internal/store/person_contact_points.go index 8e4c69b65..77a6a3a6d 100644 --- a/internal/store/person_contact_points.go +++ b/internal/store/person_contact_points.go @@ -88,8 +88,8 @@ func (s *Store) FindPersonContactPointsContext( if !query.AddressKind.Valid() { return nil, ErrInvalidContactAddressKind } - query.ScopeKind = normalizeScopeInput(query.ScopeKind) - query.ScopeValue = normalizeScopeInput(query.ScopeValue) + query.ScopeKind = trimmedOrNil(query.ScopeKind) + query.ScopeValue = trimmedOrNil(query.ScopeValue) service, hasService, err := s.resolveOptionalCommunicationServiceContext(ctx, query.ServiceSlug) if err != nil { return nil, err @@ -158,8 +158,8 @@ func (s *Store) addPersonContactPointTx( if strings.TrimSpace(input.OriginalValue) == "" { return nil, ErrContactPointValueMissing } - input.ScopeKind = normalizeScopeInput(input.ScopeKind) - input.ScopeValue = normalizeScopeInput(input.ScopeValue) + input.ScopeKind = trimmedOrNil(input.ScopeKind) + input.ScopeValue = trimmedOrNil(input.ScopeValue) service, hasService, err := resolveCommunicationServiceTx(ctx, tx, input.ServiceSlug) if err != nil { return nil, err From 4bae9bd665ec3fd848b08978ab926415681733e5 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sun, 9 Aug 2026 12:57:40 +0000 Subject: [PATCH 06/16] fix(store): preserve legacy participant identifiers --- internal/store/messages.go | 186 ++++++++++------- ..._participant_service_scope_backend_test.go | 196 +++++++++++++++--- 2 files changed, 281 insertions(+), 101 deletions(-) diff --git a/internal/store/messages.go b/internal/store/messages.go index 8e21fce2d..d9b5b9744 100644 --- a/internal/store/messages.go +++ b/internal/store/messages.go @@ -2712,45 +2712,65 @@ func (s *Store) EnsureParticipantByPhone(phone, displayName, identifierType stri // 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() 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) + err := s.withTx(func(tx *loggedTx) error { + now := s.dialect.Now() + if err := tx.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); err != nil { + return fmt.Errorf("upsert participant by phone: %w", err) + } + + // 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 { + _, 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 nil + } + serviceSlug, scopeKind, scopeValue := participantIdentifierClassificationValues( + identifierType, phone, + ) + _, 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`, + id, identifierType, phone, serviceSlug, scopeKind, scopeValue) + if err != nil { + return fmt.Errorf("insert participant identifier: %w", err) + } + return nil + }) if err != nil { - return 0, fmt.Errorf("upsert participant by phone: %w", err) - } - - // 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. - serviceSlug, scopeKind, scopeValue := participantIdentifierClassificationValues( - identifierType, phone, - ) - _, err = s.db.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`, - id, identifierType, phone, serviceSlug, scopeKind, scopeValue) - if err != nil { - return 0, fmt.Errorf("insert participant identifier: %w", err) + return 0, err } return id, nil @@ -3094,45 +3114,65 @@ 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 != "" { + _, _ = tx.Exec(` + UPDATE participants SET display_name = ? + WHERE id = ? AND (display_name IS NULL OR display_name = '') + `, displayName, participantID) + } + 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) + 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) + } + 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) + } + return nil + } + 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 nil + }) if err != nil { - return 0, fmt.Errorf("insert participant: %w", err) - } - serviceSlug, scopeKind, scopeValue := participantIdentifierClassificationValues( - identifierType, identifierValue, - ) - _, err = s.db.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 0, fmt.Errorf("insert participant identifier: %w", err) + return 0, err } return participantID, nil } diff --git a/internal/store/migrate_participant_service_scope_backend_test.go b/internal/store/migrate_participant_service_scope_backend_test.go index 45945d112..147e1cae3 100644 --- a/internal/store/migrate_participant_service_scope_backend_test.go +++ b/internal/store/migrate_participant_service_scope_backend_test.go @@ -7,9 +7,61 @@ import ( "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) @@ -21,34 +73,7 @@ func TestParticipantIdentifiersServiceScopeLegacyTableUpgrade(t *testing.T) { ) require.NoError(err) - 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(err) - } else { - 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(err) - } - } + rebuildLegacyParticipantIdentifiers(t, st) _, err = st.DB().ExecContext(ctx, st.Rebind( `DELETE FROM applied_migrations WHERE name = ?`), @@ -78,3 +103,118 @@ func TestParticipantIdentifiersServiceScopeLegacyTableUpgrade(t *testing.T) { 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") + }) + } +} From e5ca350e95dda952f8cf5038a19dd1ba34545cf8 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:25:52 +0000 Subject: [PATCH 07/16] fix(store): append participant observation ordinals --- internal/store/participant_observations.go | 9 +++- .../store/participant_observations_test.go | 48 +++++++++++++++++++ internal/store/profile_store_helpers.go | 33 +++++++++---- 3 files changed, 80 insertions(+), 10 deletions(-) diff --git a/internal/store/participant_observations.go b/internal/store/participant_observations.go index 1e858b4e4..fdc0b13a8 100644 --- a/internal/store/participant_observations.go +++ b/internal/store/participant_observations.go @@ -270,13 +270,20 @@ func (s *Store) RecordContactObservationContext( } else if !errors.Is(err, ErrProfileValueNotFound) { return err } + 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(input.Envelope.valueEnvelope(0))...) + 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, diff --git a/internal/store/participant_observations_test.go b/internal/store/participant_observations_test.go index df54eb395..191e9d0d5 100644 --- a/internal/store/participant_observations_test.go +++ b/internal/store/participant_observations_test.go @@ -65,6 +65,54 @@ func TestRecordingTheSameObservationTwiceIsIdempotent(t *testing.T) { assert.Equal(first.Observation.Envelope.ID, second.Observation.Envelope.ID) } +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) + + 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) diff --git a/internal/store/profile_store_helpers.go b/internal/store/profile_store_helpers.go index a1dcb588c..03179153f 100644 --- a/internal/store/profile_store_helpers.go +++ b/internal/store/profile_store_helpers.go @@ -21,17 +21,17 @@ func ensureProfilePersonTx(ctx context.Context, tx *loggedTx, personID int64) er return nil } -func nextProfileOrdinalTx( +func nextProfileOrdinalForOwnerTx( ctx context.Context, tx *loggedTx, - table, kindColumn string, - personID int64, + 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 person_id = ?`, table) - args := []any{personID} + FROM %s WHERE %s = ?`, table, ownerColumn) + args := []any{ownerID} if kindColumn != "" { query += fmt.Sprintf(` AND %s = ?`, kindColumn) args = append(args, kind) @@ -43,11 +43,11 @@ func nextProfileOrdinalTx( return ordinal, nil } -func resolveProfileEnvelopeTx( +func resolveProfileEnvelopeForOwnerTx( ctx context.Context, tx *loggedTx, - table, kindColumn string, - personID int64, + table, ownerColumn, kindColumn string, + ownerID int64, kind any, input ValueEnvelopeInput, ) (ValueEnvelope, error) { @@ -57,13 +57,28 @@ func resolveProfileEnvelopeTx( if input.Ordinal != nil { return input.valueEnvelope(*input.Ordinal), nil } - ordinal, err := nextProfileOrdinalTx(ctx, tx, table, kindColumn, personID, kind) + 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, From c3294e58e5c8b7b9a5e710fba4fbd0ff914c1025 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:16:08 +0000 Subject: [PATCH 08/16] chore(store): order postgres candidate fields --- internal/store/schema_pg.sql | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/store/schema_pg.sql b/internal/store/schema_pg.sql index 404a637d8..e220b5e81 100644 --- a/internal/store/schema_pg.sql +++ b/internal/store/schema_pg.sql @@ -948,14 +948,14 @@ CREATE TABLE IF NOT EXISTS identity_match_candidates ( scope_value TEXT, normalized_value TEXT, state TEXT NOT NULL DEFAULT 'candidate', - confidence DOUBLE PRECISION 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' )), + 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') From 81685cc703b28dfc923bf2eddfea031b4e652602 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:57:28 +0000 Subject: [PATCH 09/16] fix(store): retain historical profile ordinals --- .../store/participant_observations_test.go | 16 ++++ internal/store/profile_ordinals_test.go | 80 +++++++++++++++++++ internal/store/profile_store_helpers.go | 3 +- 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 internal/store/profile_ordinals_test.go diff --git a/internal/store/participant_observations_test.go b/internal/store/participant_observations_test.go index 191e9d0d5..165b6a6f2 100644 --- a/internal/store/participant_observations_test.go +++ b/internal/store/participant_observations_test.go @@ -97,6 +97,22 @@ func TestContactObservationOrdinalsPreserveExplicitAndAppendMissingValues(t *tes ) 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( 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 index 03179153f..d96e2be03 100644 --- a/internal/store/profile_store_helpers.go +++ b/internal/store/profile_store_helpers.go @@ -36,7 +36,8 @@ func nextProfileOrdinalForOwnerTx( query += fmt.Sprintf(` AND %s = ?`, kindColumn) args = append(args, kind) } - query += ` AND active_until IS NULL AND superseded_at IS NULL` + // 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) } From 71f954b8f63efc0e63d1e95e4e0dd9f893617131 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:18:38 +0000 Subject: [PATCH 10/16] fix(store): accept complete address representations --- internal/store/person_addresses.go | 2 ++ internal/store/person_addresses_test.go | 47 ++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/internal/store/person_addresses.go b/internal/store/person_addresses.go index 6e9f187d7..33a62de73 100644 --- a/internal/store/person_addresses.go +++ b/internal/store/person_addresses.go @@ -188,6 +188,7 @@ func personAddressOriginalValue(input PersonAddressInput) string { } 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) @@ -224,6 +225,7 @@ func personAddressHasValue(input PersonAddressInput) bool { 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 diff --git a/internal/store/person_addresses_test.go b/internal/store/person_addresses_test.go index cdf7fd8da..241e23c18 100644 --- a/internal/store/person_addresses_test.go +++ b/internal/store/person_addresses_test.go @@ -73,6 +73,7 @@ func TestPersonAddressDerivesOriginalValueFromAlternateRepresentation(t *testing name string value string apply func(*store.PersonAddressInput, *string) + check func(*testing.T, *store.PersonAddress) }{ { name: "free text", value: "Exampleville, CA", @@ -92,6 +93,30 @@ func TestPersonAddressDerivesOriginalValueFromAlternateRepresentation(t *testing 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) @@ -102,11 +127,22 @@ func TestPersonAddressDerivesOriginalValueFromAlternateRepresentation(t *testing } test.apply(&input, &test.value) + personID := newTestPerson(t, st) address, err := st.AddPersonAddressContext( - t.Context(), newTestPerson(t, st), input, + 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]) + } }) } } @@ -127,6 +163,15 @@ func TestPersonAddressValidationAndSupersession(t *testing.T) { 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}, From 8e3070dbdb056a6aa22f7bc1edbfd354989da7f0 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:45:59 +0000 Subject: [PATCH 11/16] fix(store): invalidate identifier-derived caches --- cmd/msgvault/cmd/cache_staleness.go | 6 +- internal/query/cache_state.go | 11 ++- internal/store/messages.go | 56 ++++++++----- .../store/participant_identifier_revision.go | 27 +++++-- .../store/participant_identifiers_test.go | 80 +++++++++++++++++++ 5 files changed, 143 insertions(+), 37 deletions(-) diff --git a/cmd/msgvault/cmd/cache_staleness.go b/cmd/msgvault/cmd/cache_staleness.go index 9f9755f2f..02a93b04b 100644 --- a/cmd/msgvault/cmd/cache_staleness.go +++ b/cmd/msgvault/cmd/cache_staleness.go @@ -36,9 +36,9 @@ type cacheStaleness struct { // 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 diff --git a/internal/query/cache_state.go b/internal/query/cache_state.go index e27bc7bcd..2d6142b44 100644 --- a/internal/query/cache_state.go +++ b/internal/query/cache_state.go @@ -45,12 +45,11 @@ 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 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"` PublishedAt time.Time `json:"published_at"` DatasetFingerprint string `json:"dataset_fingerprint"` diff --git a/internal/store/messages.go b/internal/store/messages.go index d9b5b9744..1d4c69710 100644 --- a/internal/store/messages.go +++ b/internal/store/messages.go @@ -2739,7 +2739,7 @@ func (s *Store) EnsureParticipantByPhone(phone, displayName, identifierType stri return err } if !classificationColumns { - _, err = tx.Exec(`INSERT INTO participant_identifiers ( + 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`, @@ -2747,12 +2747,12 @@ func (s *Store) EnsureParticipantByPhone(phone, displayName, identifierType stri if err != nil { return fmt.Errorf("insert participant identifier: %w", err) } - return nil + return s.bumpParticipantIdentifierRevisionIfChanged(tx, result) } serviceSlug, scopeKind, scopeValue := participantIdentifierClassificationValues( identifierType, phone, ) - _, err = tx.Exec(`INSERT INTO participant_identifiers ( + result, err := tx.Exec(`INSERT INTO participant_identifiers ( participant_id, identifier_type, identifier_value, is_primary, service_id, scope_kind, scope_value ) VALUES (?, ?, ?, TRUE, @@ -2762,12 +2762,26 @@ func (s *Store) EnsureParticipantByPhone(phone, displayName, identifierType stri 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`, + 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 nil + return s.bumpParticipantIdentifierRevisionIfChanged(tx, result) }) if err != nil { return 0, err @@ -3153,23 +3167,23 @@ func (s *Store) EnsureParticipantByIdentifier(identifierType, identifierValue, d if err != nil { return fmt.Errorf("insert participant identifier: %w", err) } - return nil - } - 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) + } 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 nil + return s.bumpParticipantIdentifierRevision(tx) }) if err != nil { return 0, err 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) +} From 30f796ca1c735ebab94d8e311806bd7f555bae85 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:47:16 +0000 Subject: [PATCH 12/16] fix(cache): track participant directory changes --- cmd/msgvault/cmd/build_cache.go | 52 ++- cmd/msgvault/cmd/build_cache_test.go | 5 +- cmd/msgvault/cmd/cache_derived.go | 71 +++- cmd/msgvault/cmd/cache_refresh_test.go | 209 +++++++++- cmd/msgvault/cmd/cache_staleness.go | 49 ++- cmd/msgvault/cmd/repair_encoding.go | 68 ++- cmd/msgvault/cmd/repair_encoding_test.go | 31 ++ internal/identityindex/fingerprint.go | 33 ++ internal/query/cache_state.go | 34 +- internal/query/cache_state_test.go | 26 +- internal/store/messages.go | 334 ++++++++++----- .../participant_display_name_revision.go | 146 +++++++ .../participant_display_name_revision_test.go | 387 ++++++++++++++++++ 13 files changed, 1254 insertions(+), 191 deletions(-) create mode 100644 internal/store/participant_display_name_revision.go create mode 100644 internal/store/participant_display_name_revision_test.go 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 02a93b04b..6b20e7e09 100644 --- a/cmd/msgvault/cmd/cache_staleness.go +++ b/cmd/msgvault/cmd/cache_staleness.go @@ -28,13 +28,13 @@ 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 rows or their // service/scope classification changed since the last build. Identifiers @@ -45,6 +45,11 @@ type cacheStaleness struct { // 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/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/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 2d6142b44..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 @@ -50,17 +51,21 @@ type CacheSyncState struct { // (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 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"` } @@ -95,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), @@ -107,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/messages.go b/internal/store/messages.go index 1d4c69710..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,30 +2764,62 @@ 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. + // 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.withTx(func(tx *loggedTx) error { now := s.dialect.Now() - if err := tx.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); err != nil { - return fmt.Errorf("upsert participant by phone: %w", err) + 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 @@ -2805,6 +2897,11 @@ 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 { @@ -3135,10 +3232,16 @@ func (s *Store) EnsureParticipantByIdentifier(identifierType, identifierValue, d `, identifierType, identifierValue).Scan(&participantID) if err == nil { if displayName != "" { - _, _ = tx.Exec(` + 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 } @@ -3154,6 +3257,9 @@ func (s *Store) EnsureParticipantByIdentifier(identifierType, identifierValue, d `, 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 @@ -3200,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 @@ -3230,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 @@ -3509,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/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") +} From 546fdea66835a443ae4e5899c0eef0d2bb942bfb Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:04:11 +0000 Subject: [PATCH 13/16] fix(store): reject incomplete scoped identifiers --- internal/store/participant_identifier_classification.go | 4 ++++ internal/store/participant_identifier_classification_test.go | 2 ++ 2 files changed, 6 insertions(+) diff --git a/internal/store/participant_identifier_classification.go b/internal/store/participant_identifier_classification.go index 242d3e452..e59bc7186 100644 --- a/internal/store/participant_identifier_classification.go +++ b/internal/store/participant_identifier_classification.go @@ -24,6 +24,8 @@ func classifyParticipantIdentifier( 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" @@ -36,6 +38,8 @@ func classifyParticipantIdentifier( if separator := strings.Index(value, ":"); separator > 0 { classification.ScopeKind = new("workspace") classification.ScopeValue = new(value[:separator]) + } else { + return participantIdentifierClassification{}, false } default: return participantIdentifierClassification{}, false diff --git a/internal/store/participant_identifier_classification_test.go b/internal/store/participant_identifier_classification_test.go index 54e7b97d1..6eed4ddd5 100644 --- a/internal/store/participant_identifier_classification_test.go +++ b/internal/store/participant_identifier_classification_test.go @@ -72,6 +72,8 @@ func TestParticipantIdentifierWritePathsClassifyServiceAndScope(t *testing.T) { 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", "", "", ""}, From bbb397bbdf8e925c2abb9aa0fa5dfac705c27cd9 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:50:03 +0000 Subject: [PATCH 14/16] fix(store): preserve manual match conflicts --- internal/store/identity_match_candidates.go | 6 ++- internal/store/identity_match_merge_test.go | 50 +++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/internal/store/identity_match_candidates.go b/internal/store/identity_match_candidates.go index 11dda7328..461e5880b 100644 --- a/internal/store/identity_match_candidates.go +++ b/internal/store/identity_match_candidates.go @@ -673,8 +673,10 @@ func reconcileIdentityMatchCandidateMergeObservationOrigin( hasObservationConflict := false allGenerated := true for _, candidate := range group { - if candidate.State == IdentityMatchStateConflict && - candidate.ObservationConflictOrigin.Valid { + if candidate.State == IdentityMatchStateConflict { + if !candidate.ObservationConflictOrigin.Valid { + return sql.NullString{} + } hasObservationConflict = true if candidate.ObservationConflictOrigin.String != observationConflictOriginGenerated { allGenerated = false diff --git a/internal/store/identity_match_merge_test.go b/internal/store/identity_match_merge_test.go index c01e12bb9..5f189455f 100644 --- a/internal/store/identity_match_merge_test.go +++ b/internal/store/identity_match_merge_test.go @@ -318,6 +318,56 @@ func TestMergeParticipantsPreservesDecisionMetadataWhenConflictWins(t *testing.T 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) From f8f8e5418fc417a33378e40d02c2a731f4a2844e Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:30:40 +0000 Subject: [PATCH 15/16] fix(store): preserve identity normalization contracts --- internal/store/communication_services.go | 29 ++--- internal/store/communication_services_test.go | 66 ++++++++++++ internal/store/identity_match_candidates.go | 5 +- internal/store/participant_observations.go | 1 + .../store/participant_observations_test.go | 101 ++++++++++++++++++ 5 files changed, 189 insertions(+), 13 deletions(-) diff --git a/internal/store/communication_services.go b/internal/store/communication_services.go index a40298b06..32d21b184 100644 --- a/internal/store/communication_services.go +++ b/internal/store/communication_services.go @@ -31,16 +31,17 @@ const ( ) 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") - 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") + 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 { @@ -257,6 +258,10 @@ func (s *Store) UpdateCommunicationServiceContext(ctx context.Context, id int64, 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 } @@ -369,8 +374,8 @@ func ValidateServiceScope(service *CommunicationService, scopeKind, scopeValue * } // trimmedOrNil trims an optional identity-key part (scope kind, scope value, -// normalized value) and treats blank input as absent, so blank-vs-NULL and -// padded variants cannot fragment identity keys. +// 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 diff --git a/internal/store/communication_services_test.go b/internal/store/communication_services_test.go index be33468f7..84037e399 100644 --- a/internal/store/communication_services_test.go +++ b/internal/store/communication_services_test.go @@ -105,6 +105,72 @@ func TestServiceSeedIsIdempotentAndPreservesUserEdits(t *testing.T) { 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) diff --git a/internal/store/identity_match_candidates.go b/internal/store/identity_match_candidates.go index 461e5880b..2720b4186 100644 --- a/internal/store/identity_match_candidates.go +++ b/internal/store/identity_match_candidates.go @@ -431,8 +431,11 @@ func (s *Store) DecideIdentityMatchCandidateContext( 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), candidateID, + state, decidedBy, stringValue(notes), + state == IdentityMatchStateConflict && decidedBy == "user", candidateID, ); err != nil { return fmt.Errorf("decide identity match candidate: %w", err) } diff --git a/internal/store/participant_observations.go b/internal/store/participant_observations.go index fdc0b13a8..de98f7e91 100644 --- a/internal/store/participant_observations.go +++ b/internal/store/participant_observations.go @@ -157,6 +157,7 @@ func (s *Store) RecordContactObservationContext( } 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 diff --git a/internal/store/participant_observations_test.go b/internal/store/participant_observations_test.go index 165b6a6f2..b75d71a55 100644 --- a/internal/store/participant_observations_test.go +++ b/internal/store/participant_observations_test.go @@ -65,6 +65,37 @@ func TestRecordingTheSameObservationTwiceIsIdempotent(t *testing.T) { 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) @@ -635,6 +666,76 @@ func TestSupersedeParticipantObservationRecomputesGeneratedConflicts(t *testing. }) } +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) From 647ef041c0bfb1b1e91283b15f8514b135b9639a Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:05:04 +0000 Subject: [PATCH 16/16] fix(store): reconcile conflicts after provider enrichment --- internal/store/participant_observations.go | 106 +++++++++--------- .../store/participant_observations_test.go | 57 ++++++++++ 2 files changed, 112 insertions(+), 51 deletions(-) diff --git a/internal/store/participant_observations.go b/internal/store/participant_observations.go index de98f7e91..7629abe64 100644 --- a/internal/store/participant_observations.go +++ b/internal/store/participant_observations.go @@ -228,6 +228,7 @@ func (s *Store) RecordContactObservationContext( input.ScopeKind, input.ScopeValue, normalized, ) providerContradicted := false + observationEnriched := false if err == nil { sameProvider := observation.ProviderUserID != nil && input.ProviderUserID != nil && @@ -257,63 +258,66 @@ func (s *Store) RecordContactObservationContext( return err } result.Observation = observation - return nil - } - // 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 + 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 } - providerContradicted = true } else if !errors.Is(err, ErrProfileValueNotFound) { return err } - 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 { + 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( diff --git a/internal/store/participant_observations_test.go b/internal/store/participant_observations_test.go index b75d71a55..c32c29c33 100644 --- a/internal/store/participant_observations_test.go +++ b/internal/store/participant_observations_test.go @@ -397,6 +397,63 @@ func TestProviderIDEnrichmentRemovesGeneratedConflict(t *testing.T) { 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)