From f3faaa6637390eddcee9470645f3ec53f083d976 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:19:48 +0000 Subject: [PATCH 1/4] feat(people): add typed temporal relationships --- api/openapi.yaml | 965 ++++++++- cmd/msgvault/cmd/person_relationship.go | 586 ++++++ cmd/msgvault/cmd/person_relationship_test.go | 270 +++ cmd/msgvault/cmd/serve.go | 67 + cmd/msgvault/cmd/serve_api_wiring_test.go | 17 + internal/api/openapi.go | 20 +- internal/api/openapi_test.go | 9 +- internal/api/person_relationships.go | 583 ++++++ internal/api/person_relationships_test.go | 227 +++ internal/api/routes.go | 1 + .../init_schema_context_internal_test.go | 26 + internal/store/person_relationship_related.go | 517 +++++ .../store/person_relationship_related_test.go | 371 ++++ .../store/person_relationship_views_test.go | 244 +++ internal/store/person_relationships.go | 673 +++++++ internal/store/person_relationships_test.go | 341 ++++ internal/store/relationship_interval.go | 75 + internal/store/relationship_interval_test.go | 101 + internal/store/relationship_type_seed.go | 276 +++ internal/store/relationship_types.go | 569 ++++++ .../store/relationship_types_internal_test.go | 35 + internal/store/relationship_types_test.go | 533 +++++ internal/store/schema.sql | 191 ++ internal/store/schema_pg.sql | 191 ++ internal/store/store.go | 8 + pkg/client/generated/client.go | 986 ++++++++-- pkg/client/generated/client_options.go | 503 +++++ pkg/client/generated/client_with_response.go | 1732 ++++++++++++++--- pkg/client/generated/enums.go | 18 + pkg/client/generated/headers.go | 36 + pkg/client/generated/paths.go | 35 + pkg/client/generated/payloads.go | 8 + pkg/client/generated/queries.go | 24 + pkg/client/generated/responses.go | 233 +++ pkg/client/generated/types.go | 288 +++ pkg/client/openapi.yaml | 963 ++++++++- web/src/lib/api/generated/schema.d.ts | 1270 ++++++++++-- 37 files changed, 12319 insertions(+), 673 deletions(-) create mode 100644 cmd/msgvault/cmd/person_relationship.go create mode 100644 cmd/msgvault/cmd/person_relationship_test.go create mode 100644 internal/api/person_relationships.go create mode 100644 internal/api/person_relationships_test.go create mode 100644 internal/store/person_relationship_related.go create mode 100644 internal/store/person_relationship_related_test.go create mode 100644 internal/store/person_relationship_views_test.go create mode 100644 internal/store/person_relationships.go create mode 100644 internal/store/person_relationships_test.go create mode 100644 internal/store/relationship_interval.go create mode 100644 internal/store/relationship_interval_test.go create mode 100644 internal/store/relationship_type_seed.go create mode 100644 internal/store/relationship_types.go create mode 100644 internal/store/relationship_types_internal_test.go create mode 100644 internal/store/relationship_types_test.go diff --git a/api/openapi.yaml b/api/openapi.yaml index 03c1f684b..7cd4e7030 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1579,6 +1579,30 @@ components: - scope_policy - normalization type: object + CreatePersonRelationshipRequest: + additionalProperties: false + properties: + end_date: + type: string + notes: + type: + - string + - "null" + relationship_type_slug: + type: string + source_person_id: + format: int64 + type: integer + start_date: + type: string + target_person_id: + format: int64 + type: integer + required: + - source_person_id + - target_person_id + - relationship_type_slug + type: object CreatePersonRequest: additionalProperties: false properties: @@ -1588,6 +1612,38 @@ components: required: - participant_id type: object + CreateRelationshipTypeRequest: + additionalProperties: false + properties: + color: + type: + - string + - "null" + description: + type: + - string + - "null" + forward_label: + type: string + icon: + type: + - string + - "null" + is_symmetric: + type: boolean + reverse_label: + type: string + slug: + type: string + vcard_related_type: + type: + - string + - "null" + required: + - slug + - forward_label + - reverse_label + type: object CreateRequest: additionalProperties: false properties: @@ -3664,6 +3720,17 @@ components: label: type: string type: object + PatchPersonRelationshipRequest: + additionalProperties: false + minProperties: 1 + properties: + end_date: + type: string + notes: + type: + - string + - "null" + type: object PatchPersonRequest: additionalProperties: false properties: @@ -3674,6 +3741,23 @@ components: required: - display_name type: object + PatchRelationshipTypeRequest: + additionalProperties: false + minProperties: 1 + properties: + color: + type: string + description: + type: string + forward_label: + type: string + icon: + type: string + reverse_label: + type: string + vcard_related_type: + type: string + type: object PatchSavedViewRequest: additionalProperties: false properties: @@ -4447,6 +4531,112 @@ components: names: $ref: "#/components/schemas/PersonNamePatchRequest" type: object + PersonRelationship: + additionalProperties: true + properties: + confidence: + format: double + type: number + created_at: + format: date-time + type: string + created_by: + type: string + end_date: + $ref: "#/components/schemas/PartialDate" + forward_label: + type: string + id: + format: int64 + type: integer + is_symmetric: + type: boolean + notes: + type: string + relationship_type_id: + format: int64 + type: integer + reverse_label: + type: string + revision: + format: int64 + type: integer + source: + type: string + source_person_id: + format: int64 + type: integer + source_ref: + type: string + start_date: + $ref: "#/components/schemas/PartialDate" + status: + type: string + target_person_id: + format: int64 + type: integer + type_slug: + type: string + updated_at: + format: date-time + type: string + updated_by: + type: string + vcard_identity: + $ref: "#/components/schemas/VCardIdentity" + required: + - id + - source_person_id + - target_person_id + - relationship_type_id + - type_slug + - forward_label + - reverse_label + - is_symmetric + - status + - source + - vcard_identity + - created_by + - updated_by + - revision + - created_at + - updated_at + type: object + PersonRelationshipView: + additionalProperties: true + properties: + counterpart_display_name: + type: string + counterpart_label: + type: string + counterpart_person_id: + format: int64 + type: integer + counterpart_vcard_uid: + type: string + direction: + type: string + relationship: + $ref: "#/components/schemas/PersonRelationship" + required: + - relationship + - direction + - counterpart_person_id + - counterpart_label + - counterpart_vcard_uid + type: object + PersonRelationshipsResponse: + additionalProperties: true + properties: + relationships: + items: + $ref: "#/components/schemas/PersonRelationshipView" + type: + - array + - "null" + required: + - relationships + type: object PersonSearchHTTPResponse: additionalProperties: true properties: @@ -4612,6 +4802,73 @@ components: - identifier - reason type: object + RelationshipReview: + additionalProperties: true + properties: + accepted_relationship_id: + format: int64 + type: integer + created_at: + format: date-time + type: string + created_by: + type: string + id: + format: int64 + type: integer + matched_person_id: + format: int64 + type: integer + person_id: + format: int64 + type: integer + raw_related_type: + type: string + raw_related_value: + type: string + reviewed_at: + format: date-time + type: string + reviewed_by: + type: string + source: + type: string + source_ref: + type: string + status: + type: string + updated_at: + format: date-time + type: string + value_kind: + type: string + vcard_identity: + $ref: "#/components/schemas/VCardIdentity" + required: + - id + - person_id + - raw_related_value + - raw_related_type + - value_kind + - status + - source + - vcard_identity + - created_by + - created_at + - updated_at + type: object + RelationshipReviewsResponse: + additionalProperties: true + properties: + reviews: + items: + $ref: "#/components/schemas/RelationshipReview" + type: + - array + - "null" + required: + - reviews + type: object RelationshipRow: additionalProperties: true properties: @@ -4724,6 +4981,74 @@ components: - cache_revision - identity_revision type: object + RelationshipType: + additionalProperties: true + properties: + color: + type: string + created_at: + format: date-time + type: string + description: + type: string + forward_label: + type: string + icon: + type: string + id: + format: int64 + type: integer + inverse_type_id: + format: int64 + type: integer + is_canonical: + type: boolean + is_deletable: + type: boolean + is_symmetric: + type: boolean + ownership: + type: string + reverse_label: + type: string + revision: + format: int64 + type: integer + slug: + type: string + universal_id: + type: string + updated_at: + format: date-time + type: string + vcard_related_type: + type: string + required: + - id + - universal_id + - slug + - forward_label + - reverse_label + - is_symmetric + - is_canonical + - ownership + - is_deletable + - revision + - created_at + - updated_at + type: object + RelationshipTypesResponse: + additionalProperties: true + properties: + relationship_types: + items: + $ref: "#/components/schemas/RelationshipType" + type: + - array + - "null" + required: + - relationship_types + type: object RelationshipsHTTPRequest: additionalProperties: false properties: @@ -6283,7 +6608,7 @@ components: type: apiKey info: title: msgvault API - version: 1.38.0 + version: 1.39.0 openapi: 3.1.0 paths: /api/ping: @@ -10374,17 +10699,36 @@ paths: summary: Get one person's canonical activity timeline tags: - Exploration - /api/v1/persons: + /api/v1/person-relationship-reviews: get: - description: "Durable persons are curated profiles; /api/v1/people exposes derived analytics groupings. The listing is deliberately unpaginated: persons exist only through explicit promotion, so the set stays small." - operationId: listPersons + operationId: listPersonRelationshipReviews + parameters: + - in: query + name: status + schema: + enum: + - pending + - accepted + - rejected + type: string + - in: query + name: person_id + schema: + format: int64 + type: integer responses: "200": content: application/json: schema: - $ref: "#/components/schemas/PersonsResponse" + $ref: "#/components/schemas/RelationshipReviewsResponse" description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error "503": content: application/json: @@ -10399,31 +10743,300 @@ paths: description: Error security: - apiKey: [] - summary: List durable person profiles + summary: List imported RELATED values awaiting review tags: - API + /api/v1/person-relationships: post: - description: Returns 201 when a new person is created, or 200 when the cluster is already represented by a person (idempotent re-promotion, which also binds any unbound cluster members). - operationId: createPerson + operationId: createPersonRelationship requestBody: content: application/json: schema: - $ref: "#/components/schemas/CreatePersonRequest" + $ref: "#/components/schemas/CreatePersonRelationshipRequest" required: true responses: - "200": + "201": content: application/json: schema: - $ref: "#/components/schemas/Person" - description: OK + $ref: "#/components/schemas/PersonRelationship" + description: Created headers: ETag: - description: Strong person profile revision tag for optimistic concurrency + description: Strong person relationship revision tag for optimistic concurrency schema: type: string - "201": + Location: + description: Created person relationship + 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 + "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: Declare a relationship between two persons + tags: + - API + /api/v1/person-relationships/{id}: + delete: + operationId: deletePersonRelationship + parameters: + - description: Person relationship ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Strong ETag returned by the latest person relationship read + in: header + name: If-Match + required: true + schema: + type: string + responses: + "204": + description: No Content + "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 + "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: Delete a person relationship + tags: + - API + get: + operationId: getPersonRelationship + parameters: + - description: Person relationship ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonRelationship" + description: OK + headers: + ETag: + description: Strong person relationship revision tag for optimistic concurrency + schema: + type: string + "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 one person relationship + tags: + - API + patch: + operationId: patchPersonRelationship + parameters: + - description: Person relationship ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Strong ETag returned by the latest person relationship read + in: header + name: If-Match + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PatchPersonRelationshipRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonRelationship" + description: OK + headers: + ETag: + description: Strong person relationship 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 + "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: End a relationship or replace its notes + tags: + - API + /api/v1/persons: + get: + description: "Durable persons are curated profiles; /api/v1/people exposes derived analytics groupings. The listing is deliberately unpaginated: persons exist only through explicit promotion, so the set stays small." + operationId: listPersons + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonsResponse" + description: OK + "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 durable person profiles + tags: + - API + post: + description: Returns 201 when a new person is created, or 200 when the cluster is already represented by a person (idempotent re-promotion, which also binds any unbound cluster members). + operationId: createPerson + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreatePersonRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Person" + description: OK + headers: + ETag: + description: Strong person profile revision tag for optimistic concurrency + schema: + type: string + "201": content: application/json: schema: @@ -11093,15 +11706,60 @@ paths: summary: Download stored inline content for one person profile media value tags: - API - /api/v1/query: - post: - operationId: runQuery - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/QueryRequest" - required: true + /api/v1/persons/{id}/relationships: + get: + operationId: listPersonRelationships + parameters: + - description: Durable person ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - in: query + name: include_ended + schema: + type: boolean + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonRelationshipsResponse" + 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 one person's relationships + tags: + - API + /api/v1/query: + post: + operationId: runQuery + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/QueryRequest" + required: true responses: "200": content: @@ -11120,6 +11778,267 @@ paths: summary: Run an aggregate query tags: - API + /api/v1/relationship-types: + get: + operationId: listRelationshipTypes + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/RelationshipTypesResponse" + description: OK + "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 person relationship types + tags: + - API + post: + operationId: createRelationshipType + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreateRelationshipTypeRequest" + required: true + responses: + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/RelationshipType" + description: Created + headers: + ETag: + description: Strong relationship type revision tag for optimistic concurrency + schema: + type: string + Location: + description: Created relationship type + schema: + type: string + "400": + 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: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Create a user-owned relationship type + tags: + - API + /api/v1/relationship-types/{id}: + delete: + operationId: deleteRelationshipType + parameters: + - description: Relationship type ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Strong ETag returned by the latest relationship type read + in: header + name: If-Match + required: true + schema: + type: string + responses: + "204": + description: No Content + "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 + "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: Delete an unused relationship type + tags: + - API + get: + operationId: getRelationshipType + parameters: + - description: Relationship type ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/RelationshipType" + description: OK + headers: + ETag: + description: Strong relationship type revision tag for optimistic concurrency + schema: + type: string + "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 relationship type + tags: + - API + patch: + operationId: patchRelationshipType + parameters: + - description: Relationship type ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Strong ETag returned by the latest relationship type read + in: header + name: If-Match + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PatchRelationshipTypeRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/RelationshipType" + description: OK + headers: + ETag: + description: Strong relationship type 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 + "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: Update a relationship type + tags: + - API /api/v1/relationships: post: operationId: listRelationships diff --git a/cmd/msgvault/cmd/person_relationship.go b/cmd/msgvault/cmd/person_relationship.go new file mode 100644 index 000000000..0e89f9282 --- /dev/null +++ b/cmd/msgvault/cmd/person_relationship.go @@ -0,0 +1,586 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + "go.kenn.io/msgvault/internal/daemonclient" + apiclient "go.kenn.io/msgvault/pkg/client" + "go.kenn.io/msgvault/pkg/client/generated" +) + +var ( + relationshipStartDate string + relationshipEndDate string + relationshipNotes string + relationshipIncludeEnded bool + relationshipTypeSymmetric bool + relationshipTypeColor string + relationshipTypeIcon string + relationshipTypeUpdateForwardLabel string + relationshipTypeUpdateReverseLabel string + relationshipTypeUpdateRelatedType string + relationshipTypeUpdateColor string + relationshipTypeUpdateIcon string + relationshipTypeUpdateDescription string + relationshipReviewStatus string +) + +var relationshipTypeCmd = &cobra.Command{ + Use: "relationship-type", + Short: "Manage person relationship types", + Long: "Manage person relationship types.\n\n" + + "A type carries a forward and a reverse label so one stored relationship renders\n" + + "correctly from both people's profiles; Msgvault never stores a mirrored second row.\n" + + "The forward label completes the sentence \" is the ___ of \".", +} + +var relationshipTypeListCmd = &cobra.Command{ + Use: cmdUseList, + Short: "List person relationship types", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + client, _, err := OpenHTTPStore(cmd.Context()) + if err != nil { + return err + } + defer func() { _ = client.Close() }() + resp, err := daemonclient.APIResponse(client, + func(api *apiclient.Client) (*generated.ListRelationshipTypesResp, error) { + return api.ListRelationshipTypesWithResponse(cmd.Context()) + }) + if err != nil { + return err + } + if resp.JSON200 == nil { + return errors.New("relationship types response was empty") + } + if personJSON { + return json.NewEncoder(cmd.OutOrStdout()).Encode(resp.JSON200.RelationshipTypes) + } + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "ID\tSLUG\tFORWARD\tREVERSE\tSYMMETRIC\tVCARD TYPE\tOWNER\tREVISION") + for _, relationshipType := range resp.JSON200.RelationshipTypes { + _, _ = fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%t\t%s\t%s\t%d\n", + relationshipType.ID, relationshipType.Slug, relationshipType.ForwardLabel, + relationshipType.ReverseLabel, relationshipType.IsSymmetric, + optionalCLIString(relationshipType.VcardRelatedType), relationshipType.Ownership, + relationshipType.Revision) + } + return w.Flush() + }, +} + +var relationshipTypeCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Create a user-owned relationship type", + Long: "Create a user-owned relationship type.\n\n" + + "The forward label completes \" is the ___ of \"; the reverse label\n" + + "completes the same sentence for the other person. For --symmetric types both labels\n" + + "must be identical.", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + slug, forward, reverse := strings.TrimSpace(args[0]), strings.TrimSpace(args[1]), strings.TrimSpace(args[2]) + if slug == "" || forward == "" || reverse == "" { + return usageErr(cmd, errors.New("slug, forward label, and reverse label are required")) + } + if relationshipTypeSymmetric && forward != reverse { + return usageErr(cmd, errors.New("a symmetric type requires identical forward and reverse labels")) + } + client, _, err := OpenHTTPStore(cmd.Context()) + if err != nil { + return err + } + defer func() { _ = client.Close() }() + body := generated.CreateRelationshipTypeBody{ + Slug: slug, ForwardLabel: forward, ReverseLabel: reverse, IsSymmetric: &relationshipTypeSymmetric, + Color: optionalCLIFlag(relationshipTypeColor), Icon: optionalCLIFlag(relationshipTypeIcon), + } + resp, err := daemonclient.APIResponseWithStatuses(client, []int{http.StatusCreated}, + func(api *apiclient.Client) (*generated.CreateRelationshipTypeResp, error) { + return api.CreateRelationshipTypeWithResponse(cmd.Context(), + &generated.CreateRelationshipTypeRequestOptions{Body: &body}) + }) + if err != nil { + return err + } + return writeCLIRelationshipType(cmd, resp.JSON201) + }, +} + +var relationshipTypeUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a relationship type's labels or presentation", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := positivePersonCLIArg(cmd, args[0], "relationship type") + if err != nil { + return err + } + body, err := relationshipTypeUpdateBody(cmd) + if err != nil { + return err + } + client, _, err := OpenHTTPStore(cmd.Context()) + if err != nil { + return err + } + defer func() { _ = client.Close() }() + current, err := getCLIRelationshipType(cmd, client, id) + if err != nil { + return err + } + if current.JSON200 == nil { + return errors.New("relationship type response was empty") + } + etag := fmt.Sprintf(`"relationship-type-%d-r%d"`, id, current.JSON200.Revision) + resp, err := daemonclient.APIResponse(client, + func(api *apiclient.Client) (*generated.PatchRelationshipTypeResp, error) { + return api.PatchRelationshipTypeWithResponse(cmd.Context(), + &generated.PatchRelationshipTypeRequestOptions{ + PathParams: &generated.PatchRelationshipTypePath{ID: id}, + Header: &generated.PatchRelationshipTypeHeaders{IfMatch: etag}, + Body: body, + }) + }) + if err != nil { + return err + } + return writeCLIRelationshipType(cmd, resp.JSON200) + }, +} + +func relationshipTypeUpdateBody(cmd *cobra.Command) (*generated.PatchRelationshipTypeBody, error) { + flags := cmd.Flags() + body := &generated.PatchRelationshipTypeBody{} + if flags.Changed("forward-label") { + value := strings.TrimSpace(relationshipTypeUpdateForwardLabel) + if value == "" { + return nil, usageErr(cmd, errors.New("forward label must not be empty")) + } + body.ForwardLabel = &value + } + if flags.Changed("reverse-label") { + value := strings.TrimSpace(relationshipTypeUpdateReverseLabel) + if value == "" { + return nil, usageErr(cmd, errors.New("reverse label must not be empty")) + } + body.ReverseLabel = &value + } + if flags.Changed("vcard-related-type") { + value := strings.TrimSpace(relationshipTypeUpdateRelatedType) + body.VcardRelatedType = &value + } + if flags.Changed("color") { + value := strings.TrimSpace(relationshipTypeUpdateColor) + body.Color = &value + } + if flags.Changed("icon") { + value := strings.TrimSpace(relationshipTypeUpdateIcon) + body.Icon = &value + } + if flags.Changed("description") { + value := strings.TrimSpace(relationshipTypeUpdateDescription) + body.Description = &value + } + if body.ForwardLabel == nil && body.ReverseLabel == nil && body.VcardRelatedType == nil && + body.Color == nil && body.Icon == nil && body.Description == nil { + return nil, usageErr(cmd, errors.New("at least one update flag is required")) + } + return body, nil +} + +var relationshipTypeDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete an unused user-owned relationship type", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := positivePersonCLIArg(cmd, args[0], "relationship type") + if err != nil { + return err + } + client, _, err := OpenHTTPStore(cmd.Context()) + if err != nil { + return err + } + defer func() { _ = client.Close() }() + current, err := getCLIRelationshipType(cmd, client, id) + if err != nil { + return err + } + if current.JSON200 == nil { + return errors.New("relationship type response was empty") + } + etag := fmt.Sprintf(`"relationship-type-%d-r%d"`, id, current.JSON200.Revision) + if _, err := daemonclient.APIResponseWithStatuses(client, []int{http.StatusNoContent}, + func(api *apiclient.Client) (*generated.DeleteRelationshipTypeResp, error) { + return api.DeleteRelationshipTypeWithResponse(cmd.Context(), + &generated.DeleteRelationshipTypeRequestOptions{ + PathParams: &generated.DeleteRelationshipTypePath{ID: id}, + Header: &generated.DeleteRelationshipTypeHeaders{IfMatch: etag}, + }) + }); err != nil { + return err + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Deleted relationship type %d\n", id) + return nil + }, +} + +var personRelationshipCmd = &cobra.Command{ + Use: "relationship", + Short: "Manage a person's typed relationships", +} + +var personRelationshipListCmd = &cobra.Command{ + Use: "list ", + Short: "List one person's relationships from that person's side", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + personID, err := positivePersonCLIArg(cmd, args[0], "person") + if err != nil { + return err + } + client, _, err := OpenHTTPStore(cmd.Context()) + if err != nil { + return err + } + defer func() { _ = client.Close() }() + query := &generated.ListPersonRelationshipsQuery{} + if relationshipIncludeEnded { + query.IncludeEnded = &relationshipIncludeEnded + } + resp, err := daemonclient.APIResponse(client, + func(api *apiclient.Client) (*generated.ListPersonRelationshipsResp, error) { + return api.ListPersonRelationshipsWithResponse(cmd.Context(), + &generated.ListPersonRelationshipsRequestOptions{ + PathParams: &generated.ListPersonRelationshipsPath{ID: personID}, Query: query, + }) + }) + if err != nil { + return err + } + if resp.JSON200 == nil { + return errors.New("person relationships response was empty") + } + if personJSON { + return json.NewEncoder(cmd.OutOrStdout()).Encode(resp.JSON200.Relationships) + } + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "ID\tCOUNTERPART\tIS\tDIRECTION\tFROM\tUNTIL\tSTATUS") + for _, view := range resp.JSON200.Relationships { + _, _ = fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\n", + view.Relationship.ID, formatCLIRelationshipCounterpart(view.CounterpartPersonID, + view.CounterpartDisplayName, view.CounterpartVcardUID), view.CounterpartLabel, + view.Direction, formatCLIPartialDate(view.Relationship.StartDate), + formatCLIPartialDate(view.Relationship.EndDate), view.Relationship.Status) + } + return w.Flush() + }, +} + +var personRelationshipAddCmd = &cobra.Command{ + Use: "add ", + Short: "Declare a relationship between two persons", + Long: "Declare a relationship between two persons.\n\n" + + "Arguments read as a sentence: \" is the of \". Exactly one row\n" + + "is stored; the type's reverse label presents the same row from the other person's side.", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + sourceID, err := positivePersonCLIArg(cmd, args[0], "source person") + if err != nil { + return err + } + typeSlug := strings.TrimSpace(args[1]) + if typeSlug == "" { + return usageErr(cmd, errors.New("relationship type slug is required")) + } + targetID, err := positivePersonCLIArg(cmd, args[2], "target person") + if err != nil { + return err + } + if sourceID == targetID { + return usageErr(cmd, errors.New("a person cannot have a relationship with itself")) + } + client, _, err := OpenHTTPStore(cmd.Context()) + if err != nil { + return err + } + defer func() { _ = client.Close() }() + body := generated.CreatePersonRelationshipBody{ + SourcePersonID: sourceID, TargetPersonID: targetID, RelationshipTypeSlug: typeSlug, + StartDate: optionalCLIFlag(relationshipStartDate), EndDate: optionalCLIFlag(relationshipEndDate), + Notes: optionalCLIFlag(relationshipNotes), + } + resp, err := daemonclient.APIResponseWithStatuses(client, []int{http.StatusCreated}, + func(api *apiclient.Client) (*generated.CreatePersonRelationshipResp, error) { + return api.CreatePersonRelationshipWithResponse(cmd.Context(), + &generated.CreatePersonRelationshipRequestOptions{Body: &body}) + }) + if err != nil { + return err + } + return writeCLIPersonRelationship(cmd, resp.JSON201) + }, +} + +var personRelationshipEndCmd = &cobra.Command{ + Use: "end ", + Short: "Record that a relationship stopped being true", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := positivePersonCLIArg(cmd, args[0], "relationship") + if err != nil { + return err + } + until := strings.TrimSpace(args[1]) + if until == "" { + return usageErr(cmd, errors.New("an end date is required")) + } + client, _, err := OpenHTTPStore(cmd.Context()) + if err != nil { + return err + } + defer func() { _ = client.Close() }() + current, err := getCLIPersonRelationship(cmd, client, id) + if err != nil { + return err + } + if current.JSON200 == nil { + return errors.New("relationship response was empty") + } + etag := fmt.Sprintf(`"person-relationship-%d-r%d"`, id, current.JSON200.Revision) + body := generated.PatchPersonRelationshipBody{EndDate: &until} + resp, err := daemonclient.APIResponse(client, + func(api *apiclient.Client) (*generated.PatchPersonRelationshipResp, error) { + return api.PatchPersonRelationshipWithResponse(cmd.Context(), + &generated.PatchPersonRelationshipRequestOptions{ + PathParams: &generated.PatchPersonRelationshipPath{ID: id}, + Header: &generated.PatchPersonRelationshipHeaders{IfMatch: etag}, Body: &body, + }) + }) + if err != nil { + return err + } + return writeCLIPersonRelationship(cmd, resp.JSON200) + }, +} + +var personRelationshipDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a relationship that should never have been recorded", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := positivePersonCLIArg(cmd, args[0], "relationship") + if err != nil { + return err + } + client, _, err := OpenHTTPStore(cmd.Context()) + if err != nil { + return err + } + defer func() { _ = client.Close() }() + current, err := getCLIPersonRelationship(cmd, client, id) + if err != nil { + return err + } + if current.JSON200 == nil { + return errors.New("relationship response was empty") + } + etag := fmt.Sprintf(`"person-relationship-%d-r%d"`, id, current.JSON200.Revision) + if _, err := daemonclient.APIResponseWithStatuses(client, []int{http.StatusNoContent}, + func(api *apiclient.Client) (*generated.DeletePersonRelationshipResp, error) { + return api.DeletePersonRelationshipWithResponse(cmd.Context(), + &generated.DeletePersonRelationshipRequestOptions{ + PathParams: &generated.DeletePersonRelationshipPath{ID: id}, + Header: &generated.DeletePersonRelationshipHeaders{IfMatch: etag}, + }) + }); err != nil { + return err + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Deleted relationship %d\n", id) + return nil + }, +} + +var personRelationshipReviewsCmd = &cobra.Command{ + Use: "reviews", + Short: "List imported vCard RELATED values awaiting review", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + status := strings.TrimSpace(relationshipReviewStatus) + query := &generated.ListPersonRelationshipReviewsQuery{} + if status != "" { + switch status { + case "pending", "accepted", "rejected": + value := generated.ListPersonRelationshipReviewsQueryStatus(status) + query.Status = &value + default: + return usageErr(cmd, errors.New("status must be pending, accepted, or rejected")) + } + } + client, _, err := OpenHTTPStore(cmd.Context()) + if err != nil { + return err + } + defer func() { _ = client.Close() }() + resp, err := daemonclient.APIResponse(client, + func(api *apiclient.Client) (*generated.ListPersonRelationshipReviewsResp, error) { + return api.ListPersonRelationshipReviewsWithResponse(cmd.Context(), + &generated.ListPersonRelationshipReviewsRequestOptions{Query: query}) + }) + if err != nil { + return err + } + if resp.JSON200 == nil { + return errors.New("person relationship reviews response was empty") + } + if personJSON { + return json.NewEncoder(cmd.OutOrStdout()).Encode(resp.JSON200.Reviews) + } + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "ID\tPERSON\tRELATED VALUE\tTYPE\tKIND\tMATCHED\tSTATUS") + for _, review := range resp.JSON200.Reviews { + matched := "-" + if review.MatchedPersonID != nil { + matched = strconv.FormatInt(*review.MatchedPersonID, 10) + } + _, _ = fmt.Fprintf(w, "%d\t%d\t%s\t%s\t%s\t%s\t%s\n", review.ID, review.PersonID, + review.RawRelatedValue, dashIfEmpty(review.RawRelatedType), review.ValueKind, matched, review.Status) + } + return w.Flush() + }, +} + +func getCLIRelationshipType( + cmd *cobra.Command, client *daemonclient.Client, id int64, +) (*generated.GetRelationshipTypeResp, error) { + return daemonclient.APIResponse(client, + func(api *apiclient.Client) (*generated.GetRelationshipTypeResp, error) { + return api.GetRelationshipTypeWithResponse(cmd.Context(), + &generated.GetRelationshipTypeRequestOptions{PathParams: &generated.GetRelationshipTypePath{ID: id}}) + }) +} + +func getCLIPersonRelationship( + cmd *cobra.Command, client *daemonclient.Client, id int64, +) (*generated.GetPersonRelationshipResp, error) { + return daemonclient.APIResponse(client, + func(api *apiclient.Client) (*generated.GetPersonRelationshipResp, error) { + return api.GetPersonRelationshipWithResponse(cmd.Context(), + &generated.GetPersonRelationshipRequestOptions{PathParams: &generated.GetPersonRelationshipPath{ID: id}}) + }) +} + +func writeCLIRelationshipType(cmd *cobra.Command, relationshipType *generated.RelationshipType) error { + if relationshipType == nil { + return errors.New("relationship type response was empty") + } + if personJSON { + return json.NewEncoder(cmd.OutOrStdout()).Encode(relationshipType) + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), + "Relationship type: %d\nSlug: %s\nForward: %s\nReverse: %s\nSymmetric: %t\nRevision: %d\n", + relationshipType.ID, relationshipType.Slug, relationshipType.ForwardLabel, + relationshipType.ReverseLabel, relationshipType.IsSymmetric, relationshipType.Revision) + return nil +} + +func writeCLIPersonRelationship(cmd *cobra.Command, edge *generated.PersonRelationship) error { + if edge == nil { + return errors.New("relationship response was empty") + } + if personJSON { + return json.NewEncoder(cmd.OutOrStdout()).Encode(edge) + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), + "Relationship: %d\nPerson %d is the %s of person %d\nFrom: %s\nUntil: %s\nStatus: %s\nRevision: %d\n", + edge.ID, edge.SourcePersonID, edge.ForwardLabel, edge.TargetPersonID, + formatCLIPartialDate(edge.StartDate), formatCLIPartialDate(edge.EndDate), edge.Status, edge.Revision) + return nil +} + +func optionalCLIFlag(value string) *string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil + } + return &trimmed +} + +func optionalCLIString(value *string) string { + if value == nil || *value == "" { + return "-" + } + return *value +} + +func formatCLIPartialDate(value *generated.PartialDate) string { + if value == nil || value.Year == nil { + return "-" + } + if value.Month == nil { + return fmt.Sprintf("%04d", *value.Year) + } + if value.Day == nil { + return fmt.Sprintf("%04d-%02d", *value.Year, *value.Month) + } + return fmt.Sprintf("%04d-%02d-%02d", *value.Year, *value.Month, *value.Day) +} + +func formatCLIRelationshipCounterpart(id int64, displayName *string, vcardUID string) string { + if displayName != nil && strings.TrimSpace(*displayName) != "" { + return fmt.Sprintf("%d (%s)", id, *displayName) + } + if strings.TrimSpace(vcardUID) != "" { + return fmt.Sprintf("%d (%s)", id, vcardUID) + } + return strconv.FormatInt(id, 10) +} + +func dashIfEmpty(value string) string { + if strings.TrimSpace(value) == "" { + return "-" + } + return value +} + +func init() { + rootCmd.AddCommand(relationshipTypeCmd) + relationshipTypeCmd.AddCommand(relationshipTypeListCmd, relationshipTypeCreateCmd, + relationshipTypeUpdateCmd, relationshipTypeDeleteCmd) + personCmd.AddCommand(personRelationshipCmd) + personRelationshipCmd.AddCommand(personRelationshipListCmd, personRelationshipAddCmd, + personRelationshipEndCmd, personRelationshipDeleteCmd, personRelationshipReviewsCmd) + + for _, command := range []*cobra.Command{ + relationshipTypeListCmd, relationshipTypeCreateCmd, relationshipTypeUpdateCmd, + personRelationshipListCmd, personRelationshipAddCmd, personRelationshipEndCmd, + personRelationshipReviewsCmd, + } { + command.Flags().BoolVar(&personJSON, flagJSON, false, "Output as JSON") + } + relationshipTypeCreateCmd.Flags().BoolVar(&relationshipTypeSymmetric, "symmetric", false, + "The relationship reads the same from both sides; both labels must be identical") + relationshipTypeCreateCmd.Flags().StringVar(&relationshipTypeColor, "color", "", "Presentation colour") + relationshipTypeCreateCmd.Flags().StringVar(&relationshipTypeIcon, "icon", "", "Presentation icon") + relationshipTypeUpdateCmd.Flags().StringVar(&relationshipTypeUpdateForwardLabel, "forward-label", "", "Forward label") + relationshipTypeUpdateCmd.Flags().StringVar(&relationshipTypeUpdateReverseLabel, "reverse-label", "", "Reverse label") + relationshipTypeUpdateCmd.Flags().StringVar(&relationshipTypeUpdateRelatedType, "vcard-related-type", "", "vCard RELATED TYPE mapping") + relationshipTypeUpdateCmd.Flags().StringVar(&relationshipTypeUpdateColor, "color", "", "Presentation colour") + relationshipTypeUpdateCmd.Flags().StringVar(&relationshipTypeUpdateIcon, "icon", "", "Presentation icon") + relationshipTypeUpdateCmd.Flags().StringVar(&relationshipTypeUpdateDescription, "description", "", "Description") + personRelationshipAddCmd.Flags().StringVar(&relationshipStartDate, "from", "", "Partial start date: YYYY, YYYY-MM, or YYYY-MM-DD") + personRelationshipAddCmd.Flags().StringVar(&relationshipEndDate, "until", "", "Partial end date for a relationship that has already ended") + personRelationshipAddCmd.Flags().StringVar(&relationshipNotes, "notes", "", "Free-text notes") + personRelationshipListCmd.Flags().BoolVar(&relationshipIncludeEnded, "include-ended", false, + "Include relationships that have ended") + personRelationshipReviewsCmd.Flags().StringVar(&relationshipReviewStatus, "status", "", + "Filter by review status: pending, accepted, or rejected") +} diff --git a/cmd/msgvault/cmd/person_relationship_test.go b/cmd/msgvault/cmd/person_relationship_test.go new file mode 100644 index 000000000..79fe71563 --- /dev/null +++ b/cmd/msgvault/cmd/person_relationship_test.go @@ -0,0 +1,270 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/config" +) + +const testPersonRelationshipJSON = `{ + "id": 11, "source_person_id": 3, "target_person_id": 4, + "relationship_type_id": 9, "type_slug": "parent", + "forward_label": "parent", "reverse_label": "child", + "is_symmetric": false, "status": "active", "source": "user", + "created_by": "user", "updated_by": "user", "revision": 1, + "created_at": "2026-07-30T12:00:00Z", "updated_at": "2026-07-30T12:00:00Z" +}` + +const testRelationshipTypeJSON = `{ + "id": 9, "universal_id": "17b0c43a-3feb-4a2d-bc47-3a87578a9abe", + "slug": "mentor", "forward_label": "mentor", "reverse_label": "mentee", + "is_symmetric": false, "is_canonical": true, "is_deletable": true, + "ownership": "user", "revision": 4, + "created_at": "2026-07-30T12:00:00Z", "updated_at": "2026-07-30T12:00:00Z" +}` + +func TestPersonRelationshipAddPostsTheDeclaredEdge(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + var body struct { + SourcePersonID int64 `json:"source_person_id"` + TargetPersonID int64 `json:"target_person_id"` + RelationshipTypeSlug string `json:"relationship_type_slug"` + StartDate *string `json:"start_date"` + Notes *string `json:"notes"` + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(http.MethodPost, r.Method) + assert.Equal("/api/v1/person-relationships", r.URL.Path) + if !assert.NoError(json.NewDecoder(r.Body).Decode(&body)) { + w.WriteHeader(http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, err := w.Write([]byte(testPersonRelationshipJSON)) + assert.NoError(err) + })) + t.Cleanup(server.Close) + withStoreResolverConfig(t, &config.Config{ + Remote: config.RemoteConfig{URL: server.URL, AllowInsecure: true}, + }) + + savedJSON, savedFrom := personJSON, relationshipStartDate + personJSON, relationshipStartDate = false, "1994" + t.Cleanup(func() { personJSON, relationshipStartDate = savedJSON, savedFrom }) + + var output bytes.Buffer + command := &cobra.Command{ + Use: personRelationshipAddCmd.Use, Args: personRelationshipAddCmd.Args, RunE: personRelationshipAddCmd.RunE, + } + command.SetOut(&output) + command.SetArgs([]string{"3", "parent", "4"}) + + require.NoError(command.Execute()) + assert.Equal(int64(3), body.SourcePersonID) + assert.Equal(int64(4), body.TargetPersonID) + assert.Equal("parent", body.RelationshipTypeSlug) + require.NotNil(body.StartDate) + assert.Equal("1994", *body.StartDate) + assert.Contains(output.String(), "Relationship: 11") + assert.Contains(output.String(), "parent") +} + +func TestPersonRelationshipListRendersBothDirections(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(http.MethodGet, r.Method) + assert.Equal("/api/v1/persons/3/relationships", r.URL.Path) + assert.Equal("true", r.URL.Query().Get("include_ended")) + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"relationships":[ + { + "relationship": {"id": 11, "source_person_id": 3, "target_person_id": 4, + "relationship_type_id": 9, "type_slug": "parent", "forward_label": "parent", + "reverse_label": "child", "is_symmetric": false, "status": "active", "source": "user", + "created_by": "user", "updated_by": "user", "revision": 1, + "created_at": "2026-07-30T12:00:00Z", "updated_at": "2026-07-30T12:00:00Z"}, + "direction": "outgoing", "counterpart_person_id": 4, "counterpart_label": "child", + "counterpart_display_name": "bob", "counterpart_vcard_uid": "17b0c43a-3feb-4a2d-bc47-3a87578a9abe"}, + { + "relationship": {"id": 12, "source_person_id": 5, "target_person_id": 3, + "relationship_type_id": 9, "type_slug": "parent", "forward_label": "parent", + "reverse_label": "child", "is_symmetric": false, "status": "ended", "end_date": {"year": 2001}, + "source": "user", "created_by": "user", "updated_by": "user", "revision": 2, + "created_at": "2026-07-30T12:00:00Z", "updated_at": "2026-07-30T12:00:00Z"}, + "direction": "incoming", "counterpart_person_id": 5, "counterpart_label": "parent", + "counterpart_vcard_uid": "2f8a1b3c-1111-4111-8111-111111111111"} + ]}`)) + assert.NoError(err) + })) + t.Cleanup(server.Close) + withStoreResolverConfig(t, &config.Config{ + Remote: config.RemoteConfig{URL: server.URL, AllowInsecure: true}, + }) + + savedJSON, savedIncludeEnded := personJSON, relationshipIncludeEnded + personJSON, relationshipIncludeEnded = false, true + t.Cleanup(func() { personJSON, relationshipIncludeEnded = savedJSON, savedIncludeEnded }) + + var output bytes.Buffer + command := &cobra.Command{ + Use: personRelationshipListCmd.Use, Args: personRelationshipListCmd.Args, RunE: personRelationshipListCmd.RunE, + } + command.SetOut(&output) + command.SetArgs([]string{"3"}) + + require.NoError(command.Execute()) + rendered := output.String() + assert.Contains(rendered, "4 (bob)") + assert.Contains(rendered, "child") + assert.Contains(rendered, "5 (2f8a1b3c-1111-4111-8111-111111111111)") + assert.Contains(rendered, "parent") + assert.Contains(rendered, "2001") +} + +func TestPersonRelationshipEndSendsIfMatchFromTheCurrentRevision(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + var sentIfMatch string + var sentBody struct { + EndDate *string `json:"end_date"` + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodGet: + assert.Equal("/api/v1/person-relationships/11", r.URL.Path) + _, err := w.Write([]byte(testPersonRelationshipJSON)) + assert.NoError(err) + case http.MethodPatch: + sentIfMatch = r.Header.Get("If-Match") + if !assert.NoError(json.NewDecoder(r.Body).Decode(&sentBody)) { + w.WriteHeader(http.StatusBadRequest) + return + } + _, err := w.Write([]byte(`{ + "id": 11, "source_person_id": 3, "target_person_id": 4, + "relationship_type_id": 9, "type_slug": "spouse", "forward_label": "spouse", + "reverse_label": "spouse", "is_symmetric": true, "status": "ended", + "end_date": {"year": 2023, "month": 5}, "source": "user", "created_by": "user", + "updated_by": "user", "revision": 5, "created_at": "2026-07-30T12:00:00Z", + "updated_at": "2026-07-30T12:00:00Z"}`)) + assert.NoError(err) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + t.Cleanup(server.Close) + withStoreResolverConfig(t, &config.Config{ + Remote: config.RemoteConfig{URL: server.URL, AllowInsecure: true}, + }) + + savedJSON := personJSON + personJSON = false + t.Cleanup(func() { personJSON = savedJSON }) + + var output bytes.Buffer + command := &cobra.Command{ + Use: personRelationshipEndCmd.Use, Args: personRelationshipEndCmd.Args, RunE: personRelationshipEndCmd.RunE, + } + command.SetOut(&output) + command.SetArgs([]string{"11", "2023-05"}) + + require.NoError(command.Execute()) + assert.Equal(`"person-relationship-11-r1"`, sentIfMatch) + require.NotNil(sentBody.EndDate) + assert.Equal("2023-05", *sentBody.EndDate) + assert.Contains(output.String(), "ended") +} + +func TestRelationshipTypeUpdateUsesCurrentRevisionETag(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + var sentIfMatch string + var sentBody struct { + ForwardLabel *string `json:"forward_label"` + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodGet: + assert.Equal("/api/v1/relationship-types/9", r.URL.Path) + _, err := w.Write([]byte(testRelationshipTypeJSON)) + assert.NoError(err) + case http.MethodPatch: + sentIfMatch = r.Header.Get("If-Match") + if !assert.NoError(json.NewDecoder(r.Body).Decode(&sentBody)) { + w.WriteHeader(http.StatusBadRequest) + return + } + _, err := w.Write([]byte(`{ + "id": 9, "universal_id": "17b0c43a-3feb-4a2d-bc47-3a87578a9abe", + "slug": "mentor", "forward_label": "guide", "reverse_label": "mentee", + "is_symmetric": false, "is_canonical": true, "is_deletable": true, + "ownership": "user", "revision": 5, + "created_at": "2026-07-30T12:00:00Z", "updated_at": "2026-07-30T12:00:00Z"}`)) + assert.NoError(err) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + t.Cleanup(server.Close) + withStoreResolverConfig(t, &config.Config{ + Remote: config.RemoteConfig{URL: server.URL, AllowInsecure: true}, + }) + + savedJSON, savedForward := personJSON, relationshipTypeUpdateForwardLabel + forwardFlag := relationshipTypeUpdateCmd.Flags().Lookup("forward-label") + savedChanged := forwardFlag.Changed + personJSON = false + t.Cleanup(func() { + personJSON, relationshipTypeUpdateForwardLabel = savedJSON, savedForward + forwardFlag.Changed = savedChanged + }) + + var output bytes.Buffer + command := &cobra.Command{ + Use: relationshipTypeUpdateCmd.Use, Args: relationshipTypeUpdateCmd.Args, RunE: relationshipTypeUpdateCmd.RunE, + } + command.Flags().AddFlagSet(relationshipTypeUpdateCmd.Flags()) + command.SetOut(&output) + command.SetArgs([]string{"9", "--forward-label", "guide"}) + + require.NoError(command.Execute()) + assert.Equal(`"relationship-type-9-r4"`, sentIfMatch) + require.NotNil(sentBody.ForwardLabel) + assert.Equal("guide", *sentBody.ForwardLabel) + assert.Contains(output.String(), "Forward: guide") +} + +func TestRelationshipTypeCreateRejectsAnEmptyReverseLabel(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + var called bool + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + called = true + })) + t.Cleanup(server.Close) + withStoreResolverConfig(t, &config.Config{ + Remote: config.RemoteConfig{URL: server.URL, AllowInsecure: true}, + }) + + command := &cobra.Command{ + Use: relationshipTypeCreateCmd.Use, Args: relationshipTypeCreateCmd.Args, RunE: relationshipTypeCreateCmd.RunE, + } + command.SetOut(&bytes.Buffer{}) + command.SetErr(&bytes.Buffer{}) + command.SetArgs([]string{"mentor", "mentor", " "}) + + require.Error(command.Execute()) + assert.False(called, "an invalid argument must not reach the daemon") +} diff --git a/cmd/msgvault/cmd/serve.go b/cmd/msgvault/cmd/serve.go index b39115cd9..130ebd3db 100644 --- a/cmd/msgvault/cmd/serve.go +++ b/cmd/msgvault/cmd/serve.go @@ -1100,6 +1100,7 @@ var _ api.PersonProfileValueStore = (*storeAPIAdapter)(nil) var _ api.CommunicationServiceStore = (*storeAPIAdapter)(nil) var _ api.AttributeDefinitionStore = (*storeAPIAdapter)(nil) var _ api.PersonAttributeStore = (*storeAPIAdapter)(nil) +var _ api.PersonRelationshipStore = (*storeAPIAdapter)(nil) var _ api.IdentityCacheRefresher = (*storeAPIAdapter)(nil) var _ api.ClusterLookupStore = (*storeAPIAdapter)(nil) var _ api.ConversationWindowStore = (*storeAPIAdapter)(nil) @@ -1989,6 +1990,72 @@ func (a *storeAPIAdapter) SupersedePersonAttributeValueContext( return a.store.SupersedePersonAttributeValueContext(ctx, input) } +func (a *storeAPIAdapter) ListRelationshipTypesContext( + ctx context.Context, +) ([]store.RelationshipType, error) { + return a.store.ListRelationshipTypesContext(ctx) +} + +func (a *storeAPIAdapter) GetRelationshipTypeContext( + ctx context.Context, id int64, +) (*store.RelationshipType, error) { + return a.store.GetRelationshipTypeContext(ctx, id) +} + +func (a *storeAPIAdapter) CreateRelationshipTypeContext( + ctx context.Context, input store.RelationshipTypeInput, +) (*store.RelationshipType, error) { + return a.store.CreateRelationshipTypeContext(ctx, input) +} + +func (a *storeAPIAdapter) UpdateRelationshipTypeContext( + ctx context.Context, id, expectedRevision int64, update store.RelationshipTypeUpdate, +) (*store.RelationshipType, error) { + return a.store.UpdateRelationshipTypeContext(ctx, id, expectedRevision, update) +} + +func (a *storeAPIAdapter) DeleteRelationshipTypeContext( + ctx context.Context, id, expectedRevision int64, +) error { + return a.store.DeleteRelationshipTypeContext(ctx, id, expectedRevision) +} + +func (a *storeAPIAdapter) AddPersonRelationshipContext( + ctx context.Context, input store.PersonRelationshipInput, +) (*store.PersonRelationship, error) { + return a.store.AddPersonRelationshipContext(ctx, input) +} + +func (a *storeAPIAdapter) GetPersonRelationshipContext( + ctx context.Context, id int64, +) (*store.PersonRelationship, error) { + return a.store.GetPersonRelationshipContext(ctx, id) +} + +func (a *storeAPIAdapter) PatchPersonRelationshipContext( + ctx context.Context, id, expectedRevision int64, patch store.PersonRelationshipPatch, actor string, +) (*store.PersonRelationship, error) { + return a.store.PatchPersonRelationshipContext(ctx, id, expectedRevision, patch, actor) +} + +func (a *storeAPIAdapter) DeletePersonRelationshipContext( + ctx context.Context, id, expectedRevision int64, +) error { + return a.store.DeletePersonRelationshipContext(ctx, id, expectedRevision) +} + +func (a *storeAPIAdapter) ListPersonRelationshipsContext( + ctx context.Context, personID int64, opts store.PersonRelationshipListOptions, +) ([]store.PersonRelationshipView, error) { + return a.store.ListPersonRelationshipsContext(ctx, personID, opts) +} + +func (a *storeAPIAdapter) ListRelationshipReviewsContext( + ctx context.Context, opts store.RelationshipReviewListOptions, +) ([]store.RelationshipReview, error) { + return a.store.ListRelationshipReviewsContext(ctx, opts) +} + func (a *storeAPIAdapter) ClusterMembers(id int64) ([]int64, error) { return a.store.ClusterMembers(id) } diff --git a/cmd/msgvault/cmd/serve_api_wiring_test.go b/cmd/msgvault/cmd/serve_api_wiring_test.go index 0bf98a932..b5313c85c 100644 --- a/cmd/msgvault/cmd/serve_api_wiring_test.go +++ b/cmd/msgvault/cmd/serve_api_wiring_test.go @@ -72,3 +72,20 @@ func TestStoreAPIAdapterServesProfileAndCommunicationServiceRoutes(t *testing.T) }) } } + +func TestStoreAPIAdapterServesPersonRelationshipRoutes(t *testing.T) { + requirements := require.New(t) + st := testutil.NewTestStore(t) + + srv := api.NewServerWithOptions(api.ServerOptions{ + Config: &config.Config{}, + Store: &storeAPIAdapter{store: st}, + Logger: slog.New(slog.DiscardHandler), + }) + request := httptest.NewRequest(http.MethodGet, "/api/v1/relationship-types", nil) + response := httptest.NewRecorder() + + srv.Router().ServeHTTP(response, request) + + requirements.Equal(http.StatusOK, response.Code, response.Body.String()) +} diff --git a/internal/api/openapi.go b/internal/api/openapi.go index 595c87e9c..11ef048f0 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -184,7 +184,11 @@ import ( // 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" +// 1.39.0 adds typed temporal person relationships: relationship-type CRUD, +// one canonical edge with endpoint-aware presentation, optimistic PATCH and +// delete operations, and unresolved RELATED review listing. Additive (minor +// bump): existing endpoints and response fields are unchanged. +const APISchemaVersion = "1.39.0" // OpenAPIDocument builds the API schema from the same Huma route registration // used by the daemon. It binds no socket and needs no database. @@ -215,9 +219,23 @@ func baseOpenAPIDocument() *huma.OpenAPI { hardenExploreSchemas(doc) hardenSearchCoverageSchemas(doc) hardenTaskLinkSchemas(doc) + hardenPersonRelationshipSchemas(doc) return doc } +func hardenPersonRelationshipSchemas(doc *huma.OpenAPI) { + if doc == nil || doc.Components == nil || doc.Components.Schemas == nil { + return + } + minProperties := 1 + for _, name := range []string{"PatchPersonRelationshipRequest", "PatchRelationshipTypeRequest"} { + patch := doc.Components.Schemas.Map()[name] + if patch != nil { + patch.MinProperties = &minProperties + } + } +} + func hardenTaskLinkSchemas(doc *huma.OpenAPI) { if doc == nil || doc.Components == nil || doc.Components.Schemas == nil { return diff --git a/internal/api/openapi_test.go b/internal/api/openapi_test.go index 0daac9f0e..04ba421f8 100644 --- a/internal/api/openapi_test.go +++ b/internal/api/openapi_test.go @@ -265,7 +265,7 @@ func TestOpenAPIPersonAttributeContract(t *testing.T) { require := require.New(t) assert := assert.New(t) - assert.Equal("1.38.0", APISchemaVersion, + assert.Equal("1.39.0", APISchemaVersion, "structured profiles are additive to the attribute schema release") doc := OpenAPIDocument() @@ -339,7 +339,7 @@ func TestOpenAPIPersonProfileMediaContentContract(t *testing.T) { require := require.New(t) assert := assert.New(t) - assert.Equal("1.38.0", APISchemaVersion, + assert.Equal("1.39.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"] @@ -371,8 +371,9 @@ func TestOpenAPIMeetingImportContract(t *testing.T) { // 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, 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") + // added in 1.37.0, raw profile media added in 1.38.0, and typed temporal + // person relationships added in 1.39.0 did not touch it. + assert.Equal("1.39.0", APISchemaVersion, "meeting import is an additive schema release") doc := OpenAPIDocument() path := doc.Paths["/api/v1/import/meeting"] diff --git a/internal/api/person_relationships.go b/internal/api/person_relationships.go new file mode 100644 index 000000000..02cdb9df6 --- /dev/null +++ b/internal/api/person_relationships.go @@ -0,0 +1,583 @@ +package api + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/danielgtaylor/huma/v2" + "go.kenn.io/msgvault/internal/store" +) + +const ( + relationshipTypesPath = "/api/v1/relationship-types" + personRelationshipsPath = "/api/v1/person-relationships" + relationshipReviewsPath = "/api/v1/person-relationship-reviews" +) + +// PersonRelationshipStore is the capability required by the curated person +// relationship HTTP surface. +type PersonRelationshipStore interface { + ListRelationshipTypesContext(ctx context.Context) ([]store.RelationshipType, error) + GetRelationshipTypeContext(ctx context.Context, id int64) (*store.RelationshipType, error) + CreateRelationshipTypeContext(ctx context.Context, input store.RelationshipTypeInput) (*store.RelationshipType, error) + UpdateRelationshipTypeContext(ctx context.Context, id, expectedRevision int64, update store.RelationshipTypeUpdate) (*store.RelationshipType, error) + DeleteRelationshipTypeContext(ctx context.Context, id, expectedRevision int64) error + AddPersonRelationshipContext(ctx context.Context, input store.PersonRelationshipInput) (*store.PersonRelationship, error) + GetPersonRelationshipContext(ctx context.Context, id int64) (*store.PersonRelationship, error) + PatchPersonRelationshipContext(ctx context.Context, id, expectedRevision int64, patch store.PersonRelationshipPatch, actor string) (*store.PersonRelationship, error) + DeletePersonRelationshipContext(ctx context.Context, id, expectedRevision int64) error + ListPersonRelationshipsContext(ctx context.Context, personID int64, opts store.PersonRelationshipListOptions) ([]store.PersonRelationshipView, error) + ListRelationshipReviewsContext(ctx context.Context, opts store.RelationshipReviewListOptions) ([]store.RelationshipReview, error) +} + +const apiRelationshipActor = "user" + +type RelationshipTypesResponse struct { + RelationshipTypes []store.RelationshipType `json:"relationship_types"` +} +type PersonRelationshipsResponse struct { + Relationships []store.PersonRelationshipView `json:"relationships"` +} +type RelationshipReviewsResponse struct { + Reviews []store.RelationshipReview `json:"reviews"` +} + +type CreateRelationshipTypeRequest struct { + Slug string `json:"slug"` + ForwardLabel string `json:"forward_label"` + ReverseLabel string `json:"reverse_label"` + IsSymmetric bool `json:"is_symmetric,omitempty"` + VCardRelatedType *string `json:"vcard_related_type,omitempty" nullable:"true"` + Color *string `json:"color,omitempty" nullable:"true"` + Icon *string `json:"icon,omitempty" nullable:"true"` + Description *string `json:"description,omitempty" nullable:"true"` +} + +type PatchRelationshipTypeRequest struct { + ForwardLabel string `json:"forward_label,omitempty"` + ReverseLabel string `json:"reverse_label,omitempty"` + VCardRelatedType string `json:"vcard_related_type,omitempty"` + Color string `json:"color,omitempty"` + Icon string `json:"icon,omitempty"` + Description string `json:"description,omitempty"` +} + +type CreatePersonRelationshipRequest struct { + SourcePersonID int64 `json:"source_person_id"` + TargetPersonID int64 `json:"target_person_id"` + RelationshipTypeSlug string `json:"relationship_type_slug"` + StartDate *string `json:"start_date,omitempty"` + EndDate *string `json:"end_date,omitempty"` + Notes *string `json:"notes,omitempty" nullable:"true"` +} + +type PatchPersonRelationshipRequest struct { + EndDate *string `json:"end_date,omitempty"` + Notes *string `json:"notes,omitempty" nullable:"true"` +} + +func (s *Server) registerPersonRelationshipRoutes(api huma.API) { + listTypes := rawAPIV1Operation("listRelationshipTypes", http.MethodGet, "/relationship-types", "List person relationship types") + listTypes.Responses = jsonResponsesFor[RelationshipTypesResponse](api) + addErrorResponses(api, listTypes.Responses, http.StatusServiceUnavailable) + registerRawHumaRoute(api, listTypes, s.handleListRelationshipTypes) + + createType := rawAPIV1Operation("createRelationshipType", http.MethodPost, "/relationship-types", "Create a user-owned relationship type") + createType.RequestBody = jsonRequestBodyFor[CreateRelationshipTypeRequest](api) + createType.Responses = jsonResponsesFor[store.RelationshipType](api, http.StatusCreated) + addRelationshipTypeHeaders(createType.Responses[httpStatusKey(http.StatusCreated)], true) + addErrorResponses(api, createType.Responses, http.StatusBadRequest, http.StatusConflict, http.StatusServiceUnavailable) + registerRawHumaRoute(api, createType, s.handleCreateRelationshipType) + + getType := rawAPIV1Operation("getRelationshipType", http.MethodGet, "/relationship-types/{id}", "Get a relationship type") + addRelationshipIDParameter(&getType, "Relationship type ID") + getType.Responses = jsonResponsesFor[store.RelationshipType](api) + addRelationshipTypeHeaders(getType.Responses[httpStatusKey(http.StatusOK)], false) + addErrorResponses(api, getType.Responses, http.StatusNotFound, http.StatusServiceUnavailable) + registerRawHumaRoute(api, getType, s.handleGetRelationshipType) + + patchType := rawAPIV1Operation("patchRelationshipType", http.MethodPatch, "/relationship-types/{id}", "Update a relationship type") + addRelationshipIDParameter(&patchType, "Relationship type ID") + addRelationshipIfMatchParameter(&patchType, "relationship type") + patchType.RequestBody = jsonRequestBodyFor[PatchRelationshipTypeRequest](api) + patchType.Responses = jsonResponsesFor[store.RelationshipType](api) + addRelationshipTypeHeaders(patchType.Responses[httpStatusKey(http.StatusOK)], false) + addErrorResponses(api, patchType.Responses, http.StatusBadRequest, http.StatusConflict, http.StatusNotFound, http.StatusPreconditionRequired, http.StatusServiceUnavailable) + registerRawHumaRoute(api, patchType, s.handlePatchRelationshipType) + + deleteType := rawAPIV1Operation("deleteRelationshipType", http.MethodDelete, "/relationship-types/{id}", "Delete an unused relationship type") + addRelationshipIDParameter(&deleteType, "Relationship type ID") + addRelationshipIfMatchParameter(&deleteType, "relationship type") + deleteType.Responses = rawHumaResponses(http.StatusNoContent) + deleteType.Responses["default"] = errorResponseFor(api) + addErrorResponses(api, deleteType.Responses, http.StatusBadRequest, http.StatusConflict, http.StatusNotFound, http.StatusPreconditionRequired, http.StatusServiceUnavailable) + registerRawHumaRoute(api, deleteType, s.handleDeleteRelationshipType) + + listForPerson := rawAPIV1Operation("listPersonRelationships", http.MethodGet, "/persons/{id}/relationships", "List one person's relationships") + addRelationshipIDParameter(&listForPerson, "Durable person ID") + listForPerson.Parameters = append(listForPerson.Parameters, &huma.Param{Name: "include_ended", In: "query", Schema: &huma.Schema{Type: huma.TypeBoolean}}) + listForPerson.Responses = jsonResponsesFor[PersonRelationshipsResponse](api) + addErrorResponses(api, listForPerson.Responses, http.StatusBadRequest, http.StatusServiceUnavailable) + registerRawHumaRoute(api, listForPerson, s.handleListPersonRelationships) + + createEdge := rawAPIV1Operation("createPersonRelationship", http.MethodPost, "/person-relationships", "Declare a relationship between two persons") + createEdge.RequestBody = jsonRequestBodyFor[CreatePersonRelationshipRequest](api) + createEdge.Responses = jsonResponsesFor[store.PersonRelationship](api, http.StatusCreated) + addPersonRelationshipHeaders(createEdge.Responses[httpStatusKey(http.StatusCreated)], true) + addErrorResponses(api, createEdge.Responses, http.StatusBadRequest, http.StatusConflict, http.StatusNotFound, http.StatusServiceUnavailable) + registerRawHumaRoute(api, createEdge, s.handleCreatePersonRelationship) + + getEdge := rawAPIV1Operation("getPersonRelationship", http.MethodGet, "/person-relationships/{id}", "Get one person relationship") + addRelationshipIDParameter(&getEdge, "Person relationship ID") + getEdge.Responses = jsonResponsesFor[store.PersonRelationship](api) + addPersonRelationshipHeaders(getEdge.Responses[httpStatusKey(http.StatusOK)], false) + addErrorResponses(api, getEdge.Responses, http.StatusNotFound, http.StatusServiceUnavailable) + registerRawHumaRoute(api, getEdge, s.handleGetPersonRelationship) + + patchEdge := rawAPIV1Operation("patchPersonRelationship", http.MethodPatch, "/person-relationships/{id}", "End a relationship or replace its notes") + addRelationshipIDParameter(&patchEdge, "Person relationship ID") + addRelationshipIfMatchParameter(&patchEdge, "person relationship") + patchEdge.RequestBody = jsonRequestBodyFor[PatchPersonRelationshipRequest](api) + patchEdge.Responses = jsonResponsesFor[store.PersonRelationship](api) + addPersonRelationshipHeaders(patchEdge.Responses[httpStatusKey(http.StatusOK)], false) + addErrorResponses(api, patchEdge.Responses, http.StatusBadRequest, http.StatusConflict, http.StatusNotFound, http.StatusPreconditionRequired, http.StatusServiceUnavailable) + registerRawHumaRoute(api, patchEdge, s.handlePatchPersonRelationship) + + deleteEdge := rawAPIV1Operation("deletePersonRelationship", http.MethodDelete, "/person-relationships/{id}", "Delete a person relationship") + addRelationshipIDParameter(&deleteEdge, "Person relationship ID") + addRelationshipIfMatchParameter(&deleteEdge, "person relationship") + deleteEdge.Responses = rawHumaResponses(http.StatusNoContent) + deleteEdge.Responses["default"] = errorResponseFor(api) + addErrorResponses(api, deleteEdge.Responses, http.StatusBadRequest, http.StatusConflict, http.StatusNotFound, http.StatusPreconditionRequired, http.StatusServiceUnavailable) + registerRawHumaRoute(api, deleteEdge, s.handleDeletePersonRelationship) + + listReviews := rawAPIV1Operation("listPersonRelationshipReviews", http.MethodGet, "/person-relationship-reviews", "List imported RELATED values awaiting review") + listReviews.Parameters = append(listReviews.Parameters, + &huma.Param{Name: "status", In: "query", Schema: &huma.Schema{Type: huma.TypeString, Enum: []any{"pending", "accepted", "rejected"}}}, + &huma.Param{Name: "person_id", In: "query", Schema: &huma.Schema{Type: huma.TypeInteger, Format: "int64"}}, + ) + listReviews.Responses = jsonResponsesFor[RelationshipReviewsResponse](api) + addErrorResponses(api, listReviews.Responses, http.StatusBadRequest, http.StatusServiceUnavailable) + registerRawHumaRoute(api, listReviews, s.handleListRelationshipReviews) +} + +func (s *Server) handleListRelationshipTypes(w http.ResponseWriter, r *http.Request) { + relationships, ok := s.personRelationshipStore(w) + if !ok { + return + } + types, err := relationships.ListRelationshipTypesContext(r.Context()) + if err != nil { + s.writeRelationshipError(w, err) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, RelationshipTypesResponse{RelationshipTypes: types}) +} + +func (s *Server) handleCreateRelationshipType(w http.ResponseWriter, r *http.Request) { + relationships, ok := s.personRelationshipStore(w) + if !ok { + return + } + var request CreateRelationshipTypeRequest + if !decodePersonRequest(w, r, &request) { + return + } + created, err := relationships.CreateRelationshipTypeContext(r.Context(), store.RelationshipTypeInput{Slug: request.Slug, ForwardLabel: request.ForwardLabel, ReverseLabel: request.ReverseLabel, IsSymmetric: request.IsSymmetric, VCardRelatedType: request.VCardRelatedType, Color: request.Color, Icon: request.Icon, Description: request.Description}) + if err != nil { + s.writeRelationshipError(w, err) + return + } + w.Header().Set("Location", relationshipTypesPath+"/"+strconv.FormatInt(created.ID, 10)) + writeRelationshipType(w, http.StatusCreated, created) +} + +func (s *Server) handleGetRelationshipType(w http.ResponseWriter, r *http.Request) { + relationships, ok := s.personRelationshipStore(w) + if !ok { + return + } + id, ok := relationshipPathID(w, r, "relationship type") + if !ok { + return + } + relationshipType, err := relationships.GetRelationshipTypeContext(r.Context(), id) + if err != nil { + s.writeRelationshipError(w, err) + return + } + writeRelationshipType(w, http.StatusOK, relationshipType) +} + +func (s *Server) handlePatchRelationshipType(w http.ResponseWriter, r *http.Request) { + relationships, ok := s.personRelationshipStore(w) + if !ok { + return + } + id, ok := relationshipPathID(w, r, "relationship type") + if !ok { + return + } + revision, ok := relationshipIfMatch(w, r, "relationship-type", id) + if !ok { + return + } + var request PatchRelationshipTypeRequest + fields, ok := decodePersonRequestFields(w, r, &request) + if !ok { + return + } + if len(fields) == 0 { + writeError(w, http.StatusBadRequest, "bad_request", "at least one relationship type field is required") + return + } + for field, value := range fields { + if isJSONNull(value) { + writeError(w, http.StatusBadRequest, "bad_request", field+" must not be null") + return + } + } + update := store.RelationshipTypeUpdate{} + if _, present := fields["forward_label"]; present { + update.ForwardLabel = &request.ForwardLabel + } + if _, present := fields["reverse_label"]; present { + update.ReverseLabel = &request.ReverseLabel + } + if _, present := fields["vcard_related_type"]; present { + update.VCardRelatedType = &request.VCardRelatedType + } + if _, present := fields["color"]; present { + update.Color = &request.Color + } + if _, present := fields["icon"]; present { + update.Icon = &request.Icon + } + if _, present := fields["description"]; present { + update.Description = &request.Description + } + updated, err := relationships.UpdateRelationshipTypeContext(r.Context(), id, revision, update) + if err != nil { + s.writeRelationshipError(w, err) + return + } + writeRelationshipType(w, http.StatusOK, updated) +} + +func (s *Server) handleDeleteRelationshipType(w http.ResponseWriter, r *http.Request) { + relationships, ok := s.personRelationshipStore(w) + if !ok { + return + } + id, ok := relationshipPathID(w, r, "relationship type") + if !ok { + return + } + revision, ok := relationshipIfMatch(w, r, "relationship-type", id) + if !ok { + return + } + if err := relationships.DeleteRelationshipTypeContext(r.Context(), id, revision); err != nil { + s.writeRelationshipError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleListPersonRelationships(w http.ResponseWriter, r *http.Request) { + relationships, ok := s.personRelationshipStore(w) + if !ok { + return + } + personID, ok := relationshipPathID(w, r, "person") + if !ok { + return + } + includeEnded, ok := relationshipBoolQuery(w, r, "include_ended") + if !ok { + return + } + views, err := relationships.ListPersonRelationshipsContext(r.Context(), personID, store.PersonRelationshipListOptions{IncludeEnded: includeEnded}) + if err != nil { + s.writeRelationshipError(w, err) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, PersonRelationshipsResponse{Relationships: views}) +} + +func (s *Server) handleCreatePersonRelationship(w http.ResponseWriter, r *http.Request) { + relationships, ok := s.personRelationshipStore(w) + if !ok { + return + } + var request CreatePersonRelationshipRequest + if !decodePersonRequest(w, r, &request) { + return + } + startDate, ok := relationshipPartialDate(w, "start_date", request.StartDate) + if !ok { + return + } + endDate, ok := relationshipPartialDate(w, "end_date", request.EndDate) + if !ok { + return + } + created, err := relationships.AddPersonRelationshipContext(r.Context(), store.PersonRelationshipInput{SourcePersonID: request.SourcePersonID, TargetPersonID: request.TargetPersonID, TypeSlug: request.RelationshipTypeSlug, StartDate: startDate, EndDate: endDate, Notes: request.Notes, Source: store.ProvenanceUser, Actor: apiRelationshipActor}) + if err != nil { + if errors.Is(err, store.ErrRelationshipTypeNotFound) { + writeError(w, http.StatusBadRequest, "invalid_relationship_type", "relationship_type_slug is not a known relationship type") + return + } + s.writeRelationshipError(w, err) + return + } + w.Header().Set("Location", personRelationshipsPath+"/"+strconv.FormatInt(created.ID, 10)) + writePersonRelationship(w, http.StatusCreated, created) +} + +func (s *Server) handleGetPersonRelationship(w http.ResponseWriter, r *http.Request) { + relationships, ok := s.personRelationshipStore(w) + if !ok { + return + } + id, ok := relationshipPathID(w, r, "person relationship") + if !ok { + return + } + edge, err := relationships.GetPersonRelationshipContext(r.Context(), id) + if err != nil { + s.writeRelationshipError(w, err) + return + } + writePersonRelationship(w, http.StatusOK, edge) +} + +func (s *Server) handlePatchPersonRelationship(w http.ResponseWriter, r *http.Request) { + relationships, ok := s.personRelationshipStore(w) + if !ok { + return + } + id, ok := relationshipPathID(w, r, "person relationship") + if !ok { + return + } + revision, ok := relationshipIfMatch(w, r, "person-relationship", id) + if !ok { + return + } + var request PatchPersonRelationshipRequest + fields, ok := decodePersonRequestFields(w, r, &request) + if !ok { + return + } + _, hasEnd := fields["end_date"] + _, hasNotes := fields["notes"] + if !hasEnd && !hasNotes { + writeError(w, http.StatusBadRequest, "bad_request", "end_date or notes is required") + return + } + var until *store.PartialDate + if hasEnd { + until, ok = relationshipPartialDate(w, "end_date", request.EndDate) + if !ok { + return + } + if until == nil { + writeError(w, http.StatusBadRequest, "bad_request", "end_date must be a partial date; to reopen a relationship, declare a new one") + return + } + } + updated, err := relationships.PatchPersonRelationshipContext(r.Context(), id, revision, store.PersonRelationshipPatch{EndDate: until, UpdateEndDate: hasEnd, Notes: request.Notes, UpdateNotes: hasNotes}, apiRelationshipActor) + if err != nil { + s.writeRelationshipError(w, err) + return + } + writePersonRelationship(w, http.StatusOK, updated) +} + +func (s *Server) handleDeletePersonRelationship(w http.ResponseWriter, r *http.Request) { + relationships, ok := s.personRelationshipStore(w) + if !ok { + return + } + id, ok := relationshipPathID(w, r, "person relationship") + if !ok { + return + } + revision, ok := relationshipIfMatch(w, r, "person-relationship", id) + if !ok { + return + } + if err := relationships.DeletePersonRelationshipContext(r.Context(), id, revision); err != nil { + s.writeRelationshipError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleListRelationshipReviews(w http.ResponseWriter, r *http.Request) { + relationships, ok := s.personRelationshipStore(w) + if !ok { + return + } + opts := store.RelationshipReviewListOptions{} + if raw := strings.TrimSpace(r.URL.Query().Get("status")); raw != "" { + switch store.RelationshipReviewStatus(raw) { + case store.RelationshipReviewPending, store.RelationshipReviewAccepted, store.RelationshipReviewRejected: + opts.Status = store.RelationshipReviewStatus(raw) + default: + writeError(w, http.StatusBadRequest, "invalid_status", "status must be pending, accepted, or rejected") + return + } + } + if raw := strings.TrimSpace(r.URL.Query().Get("person_id")); raw != "" { + personID, err := strconv.ParseInt(raw, 10, 64) + if err != nil || personID <= 0 { + writeError(w, http.StatusBadRequest, "invalid_person_id", "person_id must be a positive integer") + return + } + opts.PersonID = personID + } + reviews, err := relationships.ListRelationshipReviewsContext(r.Context(), opts) + if err != nil { + s.writeRelationshipError(w, err) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, RelationshipReviewsResponse{Reviews: reviews}) +} + +func (s *Server) personRelationshipStore(w http.ResponseWriter) (PersonRelationshipStore, bool) { + relationships, ok := s.store.(PersonRelationshipStore) + if !ok { + writeError(w, http.StatusServiceUnavailable, "person_relationships_unavailable", "Person relationships are unavailable") + } + return relationships, ok +} + +func (s *Server) writeRelationshipError(w http.ResponseWriter, err error) { + if s.writeIfContextError(w, err) { + return + } + switch { + case errors.Is(err, store.ErrRelationshipTypeNotFound): + writeError(w, http.StatusNotFound, "relationship_type_not_found", "Relationship type not found") + case errors.Is(err, store.ErrPersonRelationshipNotFound): + writeError(w, http.StatusNotFound, "person_relationship_not_found", "Person relationship not found") + case errors.Is(err, store.ErrRelationshipReviewNotFound): + writeError(w, http.StatusNotFound, "relationship_review_not_found", "Relationship review not found") + case errors.Is(err, store.ErrPersonNotFound): + writeError(w, http.StatusNotFound, "person_not_found", "Person not found") + case errors.Is(err, store.ErrRelationshipTypeRevisionConflict), errors.Is(err, store.ErrPersonRelationshipRevisionConflict): + writeError(w, http.StatusConflict, "relationship_revision_conflict", "The record changed; reload and retry") + case errors.Is(err, store.ErrRelationshipTypeSlugConflict): + writeError(w, http.StatusConflict, "relationship_type_slug_conflict", "A relationship type with that slug already exists") + case errors.Is(err, store.ErrRelationshipTypeRelatedTypeConflict): + writeError(w, http.StatusConflict, "relationship_type_related_type_conflict", "That vCard RELATED type is already mapped to another relationship type") + case errors.Is(err, store.ErrRelationshipTypeNotDeletable): + writeError(w, http.StatusConflict, "relationship_type_not_deletable", "Seeded system relationship types cannot be deleted") + case errors.Is(err, store.ErrRelationshipTypeInUse): + writeError(w, http.StatusConflict, "relationship_type_in_use", "That relationship type is still referenced by relationships") + case errors.Is(err, store.ErrPersonRelationshipDuplicate): + writeError(w, http.StatusConflict, "person_relationship_duplicate", "An active relationship of that type already exists between those people") + case errors.Is(err, store.ErrRelationshipReviewNotPending): + writeError(w, http.StatusConflict, "relationship_review_not_pending", "That relationship review has already been decided") + case errors.Is(err, store.ErrPersonRelationshipSelf), errors.Is(err, store.ErrPersonRelationshipInterval), errors.Is(err, store.ErrPersonRelationshipInvalid), errors.Is(err, store.ErrRelationshipTypeInvalid), errors.Is(err, store.ErrRelationshipTypeSymmetricLabels), errors.Is(err, store.ErrRelationshipReviewInvalid), errors.Is(err, store.ErrInvalidPartialDate), errors.Is(err, store.ErrConfidenceScope), errors.Is(err, store.ErrInvalidProvenance): + writeError(w, http.StatusBadRequest, "invalid_relationship", err.Error()) + default: + s.logger.Error("person relationship operation failed", "error", err) + writeError(w, http.StatusInternalServerError, "person_relationship_failed", "Person relationship operation failed") + } +} + +func writeRelationshipType(w http.ResponseWriter, status int, relationshipType *store.RelationshipType) { + w.Header().Set("ETag", fmt.Sprintf(`"relationship-type-%d-r%d"`, relationshipType.ID, relationshipType.Revision)) + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, status, relationshipType) +} +func writePersonRelationship(w http.ResponseWriter, status int, edge *store.PersonRelationship) { + w.Header().Set("ETag", fmt.Sprintf(`"person-relationship-%d-r%d"`, edge.ID, edge.Revision)) + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, status, edge) +} + +func addRelationshipIDParameter(operation *huma.Operation, description string) { + operation.Parameters = append(operation.Parameters, &huma.Param{Name: "id", In: "path", Required: true, Description: description, Schema: &huma.Schema{Type: huma.TypeInteger, Format: "int64"}}) +} +func addRelationshipIfMatchParameter(operation *huma.Operation, subject string) { + operation.Parameters = append(operation.Parameters, &huma.Param{Name: ifMatchHeaderName, In: "header", Required: true, Description: "Strong ETag returned by the latest " + subject + " read", Schema: &huma.Schema{Type: huma.TypeString}}) +} +func addRelationshipTypeHeaders(response *huma.Response, location bool) { + response.Headers = map[string]*huma.Param{"ETag": {Description: "Strong relationship type revision tag for optimistic concurrency", Schema: &huma.Schema{Type: huma.TypeString}}} + if location { + response.Headers["Location"] = &huma.Param{Description: "Created relationship type", Schema: &huma.Schema{Type: huma.TypeString}} + } +} +func addPersonRelationshipHeaders(response *huma.Response, location bool) { + response.Headers = map[string]*huma.Param{"ETag": {Description: "Strong person relationship revision tag for optimistic concurrency", Schema: &huma.Schema{Type: huma.TypeString}}} + if location { + response.Headers["Location"] = &huma.Param{Description: "Created person relationship", Schema: &huma.Schema{Type: huma.TypeString}} + } +} + +func relationshipPathID(w http.ResponseWriter, r *http.Request, subject string) (int64, bool) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id <= 0 { + writeError(w, http.StatusBadRequest, "invalid_id", strings.ToUpper(subject[:1])+subject[1:]+" ID must be a positive integer") + return 0, false + } + return id, true +} +func relationshipIfMatch(w http.ResponseWriter, r *http.Request, prefix string, id int64) (int64, bool) { + 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 + } + if len(values) != 1 { + writeError(w, http.StatusBadRequest, "invalid_if_match", "If-Match must contain exactly one revision tag") + return 0, false + } + expected := fmt.Sprintf(`"%s-%d-r`, prefix, id) + value := strings.TrimSpace(values[0]) + if !strings.HasPrefix(value, expected) || !strings.HasSuffix(value, `"`) { + writeError(w, http.StatusBadRequest, "invalid_if_match", "If-Match is not a "+prefix+" revision tag") + return 0, false + } + revision, err := strconv.ParseInt(strings.TrimSuffix(strings.TrimPrefix(value, expected), `"`), 10, 64) + if err != nil || revision <= 0 { + writeError(w, http.StatusBadRequest, "invalid_if_match", "If-Match is not a "+prefix+" revision tag") + return 0, false + } + return revision, true +} +func relationshipPartialDate(w http.ResponseWriter, field string, value *string) (*store.PartialDate, bool) { + if value == nil || strings.TrimSpace(*value) == "" { + return nil, true + } + parsed, err := store.ParseRelationshipDate(*value) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_partial_date", field+" must be YYYY, YYYY-MM, or YYYY-MM-DD") + return nil, false + } + return &parsed, true +} +func relationshipBoolQuery(w http.ResponseWriter, r *http.Request, name string) (bool, bool) { + switch strings.TrimSpace(r.URL.Query().Get(name)) { + case "": + return false, true + case "true", "1": + return true, true + case "false", "0": + return false, true + default: + writeError(w, http.StatusBadRequest, "invalid_query_parameter", name+" must be true or false") + return false, false + } +} diff --git a/internal/api/person_relationships_test.go b/internal/api/person_relationships_test.go new file mode 100644 index 000000000..f227f3df6 --- /dev/null +++ b/internal/api/person_relationships_test.go @@ -0,0 +1,227 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/store" +) + +func TestPersonRelationshipHTTPRendersBothEndpointsFromOneRow(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + srv, st := newIdentityLinkTestServer(t) + alice, bob := mustHTTPPersons(t, st) + + created := personRequest(t, srv, http.MethodPost, personRelationshipsPath, + fmt.Appendf(nil, `{"source_person_id":%d,"target_person_id":%d,"relationship_type_slug":"parent","start_date":"1994"}`, alice, bob), "") + require.Equal(http.StatusCreated, created.Code) + var edge store.PersonRelationship + require.NoError(json.Unmarshal(created.Body.Bytes(), &edge)) + assert.Equal(alice, edge.SourcePersonID) + assert.Equal(bob, edge.TargetPersonID) + assert.Equal("parent", edge.TypeSlug) + assert.Equal(fmt.Sprintf(`"person-relationship-%d-r%d"`, edge.ID, edge.Revision), created.Header().Get("ETag")) + assert.Equal(fmt.Sprintf("%s/%d", personRelationshipsPath, edge.ID), created.Header().Get("Location")) + + fromAlice := personRequest(t, srv, http.MethodGet, fmt.Sprintf("%s/%d/relationships", personsPath, alice), nil, "") + require.Equal(http.StatusOK, fromAlice.Code) + var aliceView PersonRelationshipsResponse + require.NoError(json.Unmarshal(fromAlice.Body.Bytes(), &aliceView)) + require.Len(aliceView.Relationships, 1) + assert.Equal(store.RelationshipDirectionOutgoing, aliceView.Relationships[0].Direction) + assert.Equal("child", aliceView.Relationships[0].CounterpartLabel) + assert.Equal(bob, aliceView.Relationships[0].CounterpartPersonID) + + fromBob := personRequest(t, srv, http.MethodGet, fmt.Sprintf("%s/%d/relationships", personsPath, bob), nil, "") + require.Equal(http.StatusOK, fromBob.Code) + var bobView PersonRelationshipsResponse + require.NoError(json.Unmarshal(fromBob.Body.Bytes(), &bobView)) + require.Len(bobView.Relationships, 1) + assert.Equal(edge.ID, bobView.Relationships[0].Relationship.ID) + assert.Equal(store.RelationshipDirectionIncoming, bobView.Relationships[0].Direction) + assert.Equal("parent", bobView.Relationships[0].CounterpartLabel) +} + +func TestPersonRelationshipHTTPPatchIsAtomic(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + srv, st := newIdentityLinkTestServer(t) + alice, bob := mustHTTPPersons(t, st) + created := personRequest(t, srv, http.MethodPost, personRelationshipsPath, + fmt.Appendf(nil, `{"source_person_id":%d,"target_person_id":%d,"relationship_type_slug":"spouse"}`, alice, bob), "") + require.Equal(http.StatusCreated, created.Code) + var edge store.PersonRelationship + require.NoError(json.Unmarshal(created.Body.Bytes(), &edge)) + path := fmt.Sprintf("%s/%d", personRelationshipsPath, edge.ID) + + invalidNotes := fmt.Sprintf(`{"end_date":"2023-05","notes":"%s"}`, strings.Repeat("x", 4097)) + bad := personRequest(t, srv, http.MethodPatch, path, []byte(invalidNotes), created.Header().Get("ETag")) + assert.Equal(http.StatusBadRequest, bad.Code) + unchanged := personRequest(t, srv, http.MethodGet, path, nil, "") + require.Equal(http.StatusOK, unchanged.Code) + var before store.PersonRelationship + require.NoError(json.Unmarshal(unchanged.Body.Bytes(), &before)) + assert.Nil(before.EndDate) + assert.Equal(edge.Revision, before.Revision) + + patched := personRequest(t, srv, http.MethodPatch, path, + []byte(`{"end_date":"2023-05","notes":"met at the block party"}`), created.Header().Get("ETag")) + require.Equal(http.StatusOK, patched.Code) + var after store.PersonRelationship + require.NoError(json.Unmarshal(patched.Body.Bytes(), &after)) + require.NotNil(after.EndDate) + require.NotNil(after.Notes) + assert.Equal("2023-05", after.EndDate.String()) + assert.Equal("met at the block party", *after.Notes) + assert.Equal(edge.Revision+1, after.Revision) + assert.Equal(fmt.Sprintf(`"person-relationship-%d-r%d"`, edge.ID, edge.Revision+1), patched.Header().Get("ETag")) + + assert.Equal(http.StatusBadRequest, personRequest(t, srv, http.MethodPatch, path, []byte(`{"end_date":null}`), patched.Header().Get("ETag")).Code) + assert.Equal(http.StatusBadRequest, personRequest(t, srv, http.MethodPatch, path, []byte(`{}`), patched.Header().Get("ETag")).Code) +} + +func TestPersonRelationshipHTTPEndPreconditionsAndConflicts(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + srv, st := newIdentityLinkTestServer(t) + alice, bob := mustHTTPPersons(t, st) + created := personRequest(t, srv, http.MethodPost, personRelationshipsPath, + fmt.Appendf(nil, `{"source_person_id":%d,"target_person_id":%d,"relationship_type_slug":"spouse"}`, alice, bob), "") + require.Equal(http.StatusCreated, created.Code) + var edge store.PersonRelationship + require.NoError(json.Unmarshal(created.Body.Bytes(), &edge)) + path := fmt.Sprintf("%s/%d", personRelationshipsPath, edge.ID) + + assert.Equal(http.StatusConflict, personRequest(t, srv, http.MethodPost, personRelationshipsPath, + fmt.Appendf(nil, `{"source_person_id":%d,"target_person_id":%d,"relationship_type_slug":"spouse"}`, bob, alice), "").Code) + assert.Equal(http.StatusPreconditionRequired, personRequest(t, srv, http.MethodPatch, path, []byte(`{"end_date":"2023-05"}`), "").Code) + ended := personRequest(t, srv, http.MethodPatch, path, []byte(`{"end_date":"2023-05"}`), created.Header().Get("ETag")) + require.Equal(http.StatusOK, ended.Code) + assert.Equal(http.StatusConflict, personRequest(t, srv, http.MethodPatch, path, []byte(`{"end_date":"2024"}`), created.Header().Get("ETag")).Code) + active := personRequest(t, srv, http.MethodGet, fmt.Sprintf("%s/%d/relationships", personsPath, alice), nil, "") + var activeView PersonRelationshipsResponse + require.NoError(json.Unmarshal(active.Body.Bytes(), &activeView)) + assert.Empty(activeView.Relationships) + all := personRequest(t, srv, http.MethodGet, fmt.Sprintf("%s/%d/relationships?include_ended=true", personsPath, alice), nil, "") + var allView PersonRelationshipsResponse + require.NoError(json.Unmarshal(all.Body.Bytes(), &allView)) + require.Len(allView.Relationships, 1) + assert.Equal(http.StatusNoContent, personRequest(t, srv, http.MethodDelete, path, nil, ended.Header().Get("ETag")).Code) +} + +func TestPersonRelationshipHTTPRejectsInvalidRequests(t *testing.T) { + srv, st := newIdentityLinkTestServer(t) + alice, bob := mustHTTPPersons(t, st) + for _, test := range []struct { + name, body string + want int + }{ + {"self", fmt.Sprintf(`{"source_person_id":%d,"target_person_id":%d,"relationship_type_slug":"friend"}`, alice, alice), http.StatusBadRequest}, + {"unknown type", fmt.Sprintf(`{"source_person_id":%d,"target_person_id":%d,"relationship_type_slug":"nemesis"}`, alice, bob), http.StatusBadRequest}, + {"unknown person", fmt.Sprintf(`{"source_person_id":%d,"target_person_id":999999,"relationship_type_slug":"friend"}`, alice), http.StatusNotFound}, + {"malformed date", fmt.Sprintf(`{"source_person_id":%d,"target_person_id":%d,"relationship_type_slug":"friend","start_date":"19th of May"}`, alice, bob), http.StatusBadRequest}, + {"unknown field", fmt.Sprintf(`{"source_person_id":%d,"target_person_id":%d,"relationship_type_slug":"friend","confidence":0.9}`, alice, bob), http.StatusBadRequest}, + } { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, personRequest(t, srv, http.MethodPost, personRelationshipsPath, []byte(test.body), "").Code) + }) + } + assert.Equal(t, http.StatusBadRequest, personRequest(t, srv, http.MethodGet, personsPath+"/0/relationships", nil, "").Code) +} + +func TestRelationshipTypeHTTPCRUDAndSystemProtection(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + srv, _ := newIdentityLinkTestServer(t) + listed := personRequest(t, srv, http.MethodGet, relationshipTypesPath, nil, "") + require.Equal(http.StatusOK, listed.Code) + var types RelationshipTypesResponse + require.NoError(json.Unmarshal(listed.Body.Bytes(), &types)) + require.Len(types.RelationshipTypes, 19) + created := personRequest(t, srv, http.MethodPost, relationshipTypesPath, []byte(`{"slug":"mentor","forward_label":"mentor","reverse_label":"mentee"}`), "") + require.Equal(http.StatusCreated, created.Code) + var mentor store.RelationshipType + require.NoError(json.Unmarshal(created.Body.Bytes(), &mentor)) + patched := personRequest(t, srv, http.MethodPatch, fmt.Sprintf("%s/%d", relationshipTypesPath, mentor.ID), []byte(`{"forward_label":"coach","color":"#112233"}`), created.Header().Get("ETag")) + require.Equal(http.StatusOK, patched.Code) + assert.Equal(http.StatusConflict, personRequest(t, srv, http.MethodPost, relationshipTypesPath, []byte(`{"slug":"friend","forward_label":"friend","reverse_label":"friend","is_symmetric":true}`), "").Code) + assert.Equal(http.StatusNoContent, personRequest(t, srv, http.MethodDelete, fmt.Sprintf("%s/%d", relationshipTypesPath, mentor.ID), nil, patched.Header().Get("ETag")).Code) +} + +func TestRelationshipTypeHTTPRejectsEmptyAndNullPatchesWithoutRevisionBump(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + srv, _ := newIdentityLinkTestServer(t) + created := personRequest(t, srv, http.MethodPost, relationshipTypesPath, + []byte(`{"slug":"mentor","forward_label":"mentor","reverse_label":"mentee"}`), "") + require.Equal(http.StatusCreated, created.Code) + var mentor store.RelationshipType + require.NoError(json.Unmarshal(created.Body.Bytes(), &mentor)) + path := fmt.Sprintf("%s/%d", relationshipTypesPath, mentor.ID) + + empty := personRequest(t, srv, http.MethodPatch, path, []byte(`{}`), created.Header().Get("ETag")) + assert.Equal(http.StatusBadRequest, empty.Code) + explicitNull := personRequest(t, srv, http.MethodPatch, path, + []byte(`{"forward_label":null}`), created.Header().Get("ETag")) + assert.Equal(http.StatusBadRequest, explicitNull.Code) + reloaded := personRequest(t, srv, http.MethodGet, path, nil, "") + require.Equal(http.StatusOK, reloaded.Code) + var unchanged store.RelationshipType + require.NoError(json.Unmarshal(reloaded.Body.Bytes(), &unchanged)) + assert.Equal(mentor.Revision, unchanged.Revision) + assert.Equal("mentor", unchanged.ForwardLabel) +} + +func TestPersonRelationshipOpenAPIPatchContracts(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + schemas := OpenAPIDocument().Components.Schemas.Map() + edgePatch := schemas["PatchPersonRelationshipRequest"] + require.NotNil(edgePatch) + require.NotNil(edgePatch.MinProperties) + assert.Equal(1, *edgePatch.MinProperties) + require.NotNil(edgePatch.Properties["end_date"]) + assert.False(edgePatch.Properties["end_date"].Nullable) + + typePatch := schemas["PatchRelationshipTypeRequest"] + require.NotNil(typePatch) + require.NotNil(typePatch.MinProperties) + assert.Equal(1, *typePatch.MinProperties) + for _, field := range []string{"forward_label", "reverse_label", "vcard_related_type", "color", "icon", "description"} { + require.NotNil(typePatch.Properties[field], field) + assert.False(typePatch.Properties[field].Nullable, field) + } +} + +func TestRelationshipReviewHTTPListsStagedValues(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + srv, st := newIdentityLinkTestServer(t) + alice, _ := mustHTTPPersons(t, st) + _, err := st.ResolveRelatedValueContext(t.Context(), store.RelatedImport{PersonID: alice, RawValue: "Bob from the gym", RawType: "friend", ValueKind: store.RelatedValueKindText, Source: store.ProvenanceVCardImport, Actor: "system"}) + require.NoError(err) + listed := personRequest(t, srv, http.MethodGet, relationshipReviewsPath+"?status=pending", nil, "") + require.Equal(http.StatusOK, listed.Code) + var reviews RelationshipReviewsResponse + require.NoError(json.Unmarshal(listed.Body.Bytes(), &reviews)) + require.Len(reviews.Reviews, 1) + assert.Equal("Bob from the gym", reviews.Reviews[0].RawRelatedValue) + assert.Equal(http.StatusBadRequest, personRequest(t, srv, http.MethodGet, relationshipReviewsPath+"?status=maybe", nil, "").Code) +} + +func mustHTTPPersons(t *testing.T, st *stubIdentityCacheStore) (int64, int64) { + t.Helper() + aliceParticipant := st.mustParticipant(t, "alice@example.com", "alice", "example.com") + bobParticipant := st.mustParticipant(t, "bob@example.com", "bob", "example.com") + alice, _, err := st.CreatePersonFromParticipant(aliceParticipant) + require.NoError(t, err) + bob, _, err := st.CreatePersonFromParticipant(bobParticipant) + require.NoError(t, err) + return alice.ID, bob.ID +} diff --git a/internal/api/routes.go b/internal/api/routes.go index 92531334c..7b7a9bd02 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -228,6 +228,7 @@ func (s *Server) registerHumaRoutes(api huma.API, apiV1 huma.API) { s.registerPersonAttributeRoutes(apiV1) s.registerPeopleRoutes(apiV1) s.registerRelationshipRoutes(apiV1) + s.registerPersonRelationshipRoutes(apiV1) s.registerIdentityLinkRoutes(apiV1) s.registerTaskIntegrationRoutes(apiV1) s.registerTaskLinkRoutes(apiV1) diff --git a/internal/store/init_schema_context_internal_test.go b/internal/store/init_schema_context_internal_test.go index 678d52df7..9b06fd232 100644 --- a/internal/store/init_schema_context_internal_test.go +++ b/internal/store/init_schema_context_internal_test.go @@ -142,6 +142,32 @@ func TestInitSchemaContext_MigrationLedgerStopsWhenTheContextIsCancelled(t *test } } +func TestInitSchemaContext_RelationshipSeedHonoursContext(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + + st, err := Open(filepath.Join(t.TempDir(), "relationship-seed.db")) + require.NoError(err, "open store") + t.Cleanup(func() { _ = st.Close() }) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + trigger := cancelAtStatement{ + stop: "FROM relationship_types WHERE universal_id = ?", + cancel: cancel, + } + trigger.install(st.db) + + err = st.InitSchemaContext(ctx) + + require.True(trigger.fired, "the relationship seed query was never issued") + require.Error(err, "a cancelled relationship seed must stop schema initialisation") + require.ErrorIs(err, context.Canceled, "and report it as cancellation") + var count int + require.NoError(st.db.QueryRow(`SELECT COUNT(*) FROM relationship_types`).Scan(&count)) + assert.Zero(count, "a cancelled relationship seed must roll back its transaction") +} + // TestInitSchemaContext_LegacyPhoneMergeStopsWhenTheContextIsCancelled covers // the link-graph rewrite inside the phone-unique migration's participant merge. // diff --git a/internal/store/person_relationship_related.go b/internal/store/person_relationship_related.go new file mode 100644 index 000000000..cdc23840f --- /dev/null +++ b/internal/store/person_relationship_related.go @@ -0,0 +1,517 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" +) + +var ( + ErrRelationshipReviewNotFound = errors.New("relationship review not found") + ErrRelationshipReviewNotPending = errors.New("relationship review is not pending") + ErrRelationshipReviewInvalid = errors.New("invalid relationship review input") +) + +const ( + maxRelatedValueBytes = 1024 + maxRelatedTypeBytes = 128 + urnUUIDPrefix = "urn:uuid:" +) + +// RelatedValueKind records whether RELATED carried a URI or free text. +type RelatedValueKind string + +const ( + RelatedValueKindURI RelatedValueKind = "uri" + RelatedValueKindText RelatedValueKind = "text" +) + +func (k RelatedValueKind) valid() bool { + return k == RelatedValueKindURI || k == RelatedValueKindText +} + +// RelationshipReviewStatus is the lifecycle of an unresolved RELATED value. +type RelationshipReviewStatus string + +const ( + RelationshipReviewPending RelationshipReviewStatus = "pending" + RelationshipReviewAccepted RelationshipReviewStatus = "accepted" + RelationshipReviewRejected RelationshipReviewStatus = "rejected" +) + +// RelationshipReview preserves an imported assertion until a human resolves it. +type RelationshipReview struct { + ID int64 `json:"id"` + PersonID int64 `json:"person_id"` + RawRelatedValue string `json:"raw_related_value"` + RawRelatedType string `json:"raw_related_type"` + ValueKind RelatedValueKind `json:"value_kind"` + MatchedPersonID *int64 `json:"matched_person_id,omitempty"` + Status RelationshipReviewStatus `json:"status"` + AcceptedRelationshipID *int64 `json:"accepted_relationship_id,omitempty"` + Source Provenance `json:"source"` + SourceRef *string `json:"source_ref,omitempty"` + VCardIdentity VCardIdentity `json:"vcard_identity"` + CreatedBy string `json:"created_by"` + ReviewedBy *string `json:"reviewed_by,omitempty"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// RelatedImport is one already-parsed RELATED property occurrence. RawType is +// exactly one TYPE parameter value; callers must split parameter lists first. +type RelatedImport struct { + PersonID int64 + RawValue string + RawType string + ValueKind RelatedValueKind + Source Provenance + SourceRef *string + VCardIdentity VCardIdentity + Actor string +} + +// RelatedResolution contains either an automatic edge or a durable review. +type RelatedResolution struct { + Relationship *PersonRelationship `json:"relationship,omitempty"` + Review *RelationshipReview `json:"review,omitempty"` +} + +// Status returns the staged review status, or empty for an automatic edge. +func (r RelatedResolution) Status() RelationshipReviewStatus { + if r.Review == nil { + return "" + } + return r.Review.Status +} + +// RelationshipReviewListOptions filters the review queue. +type RelationshipReviewListOptions struct { + Status RelationshipReviewStatus + PersonID int64 +} + +// ResolvePersonByVCardUIDContext matches canonical vCard UID byte-for-byte. +func (s *Store) ResolvePersonByVCardUIDContext(ctx context.Context, uid string) (*Person, error) { + if uid == "" { + return nil, fmt.Errorf("%w: empty vCard UID", ErrPersonNotFound) + } + var person *Person + err := s.withTxContext(ctx, func(tx *loggedTx) error { + var id int64 + err := tx.QueryRowContext(ctx, `SELECT id FROM persons WHERE vcard_uid = ?`, uid).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("%w: vcard_uid %q", ErrPersonNotFound, uid) + } + if err != nil { + return fmt.Errorf("resolve person by vCard UID: %w", err) + } + person, err = s.getPersonTx(ctx, tx, id) + return err + }) + if err != nil { + return nil, err + } + return person, nil +} + +// ResolveRelatedValueContext automatically links only an exact UID plus a +// recognized relationship type. All other inputs remain durable reviews. +func (s *Store) ResolveRelatedValueContext(ctx context.Context, in RelatedImport) (*RelatedResolution, error) { + source, err := ParseProvenance(string(in.Source)) + if err != nil { + return nil, err + } + in.Source = source + actor, err := validateRelationshipActor(in.Actor) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrRelationshipReviewInvalid, err) + } + if in.PersonID <= 0 { + return nil, fmt.Errorf("%w: person ID must be positive", ErrRelationshipReviewInvalid) + } + if !in.ValueKind.valid() { + return nil, fmt.Errorf("%w: value kind %q", ErrRelationshipReviewInvalid, in.ValueKind) + } + if strings.TrimSpace(in.RawValue) == "" || len(in.RawValue) > maxRelatedValueBytes { + return nil, fmt.Errorf("%w: RELATED value is required and at most %d bytes", ErrRelationshipReviewInvalid, maxRelatedValueBytes) + } + if len(in.RawType) > maxRelatedTypeBytes || strings.Contains(in.RawType, ",") { + return nil, fmt.Errorf("%w: RELATED TYPE must be one value of at most %d bytes", ErrRelationshipReviewInvalid, maxRelatedTypeBytes) + } + + matchedPersonID, personMatched, err := s.matchRelatedValueToPerson(ctx, in) + if err != nil { + return nil, err + } + // The review schema forbids self matches and an import must never create a + // self edge. Preserve the raw assertion, but leave its match unset. + if personMatched && matchedPersonID == in.PersonID { + personMatched = false + } + relationshipType, typeMatched, err := s.relationshipTypeForRelatedType(ctx, in.RawType) + if err != nil { + return nil, err + } + if personMatched && typeMatched { + edge, err := s.AddPersonRelationshipContext(ctx, PersonRelationshipInput{ + SourcePersonID: in.PersonID, TargetPersonID: matchedPersonID, + TypeSlug: relationshipType.Slug, Source: in.Source, SourceRef: in.SourceRef, + VCardIdentity: in.VCardIdentity, Actor: actor, + }) + if err == nil { + return &RelatedResolution{Relationship: edge}, nil + } + if !errors.Is(err, ErrPersonRelationshipDuplicate) { + return nil, err + } + existing, findErr := s.activePersonRelationshipContext(ctx, in.PersonID, matchedPersonID, relationshipType.Slug) + if findErr != nil { + return nil, findErr + } + return &RelatedResolution{Relationship: existing}, nil + } + + review, err := s.stageRelationshipReview(ctx, in, matchedPersonID, personMatched, actor) + if err != nil { + return nil, err + } + return &RelatedResolution{Review: review}, nil +} + +func (s *Store) matchRelatedValueToPerson(ctx context.Context, in RelatedImport) (int64, bool, error) { + candidates := []string{in.RawValue} + if lower := strings.ToLower(in.RawValue); strings.HasPrefix(lower, urnUUIDPrefix) { + rawUUID := strings.TrimPrefix(lower, urnUUIDPrefix) + // uuid.Parse accepts compact and brace-wrapped forms too. RELATED's + // narrow exception is only a canonical hyphenated UUID after urn:uuid:. + if parsed, err := uuid.Parse(rawUUID); len(rawUUID) == 36 && err == nil && parsed.String() == rawUUID { + candidates = append(candidates, parsed.String()) + } + } + for _, candidate := range candidates { + person, err := s.ResolvePersonByVCardUIDContext(ctx, candidate) + if errors.Is(err, ErrPersonNotFound) { + continue + } + if err != nil { + return 0, false, err + } + return person.ID, true, nil + } + return 0, false, nil +} + +func (s *Store) relationshipTypeForRelatedType(ctx context.Context, rawType string) (RelationshipType, bool, error) { + normalized := strings.ToLower(strings.TrimSpace(rawType)) + if normalized == "" { + return RelationshipType{}, false, nil + } + relationshipType, err := scanRelationshipType(s.db.QueryRowContext(ctx, + `SELECT `+relationshipTypeColumns+` FROM relationship_types WHERE vcard_related_type = ?`, normalized)) + if errors.Is(err, sql.ErrNoRows) { + return RelationshipType{}, false, nil + } + if err != nil { + return RelationshipType{}, false, fmt.Errorf("look up relationship type for RELATED TYPE %q: %w", rawType, err) + } + return *relationshipType, true, nil +} + +// activePersonRelationshipContext looks up exactly the canonical triple that +// AddPersonRelationshipContext would write. It never searches both endpoint +// orientations, because reciprocal asymmetric edges may coexist. +func (s *Store) activePersonRelationshipContext(ctx context.Context, sourceID, targetID int64, typeSlug string) (*PersonRelationship, error) { + var edge *PersonRelationship + err := s.withTxContext(ctx, func(tx *loggedTx) error { + relationshipType, canonicalSource, canonicalTarget, err := s.canonicalRelationshipEndpointsTx(ctx, tx, sourceID, targetID, typeSlug) + if err != nil { + return err + } + edge, err = scanPersonRelationship(tx.QueryRowContext(ctx, + `SELECT `+personRelationshipColumns+personRelationshipFrom+` + WHERE r.source_person_id = ? AND r.target_person_id = ? + AND r.relationship_type_id = ? AND r.end_year IS NULL`, + canonicalSource, canonicalTarget, relationshipType.ID)) + if errors.Is(err, sql.ErrNoRows) { + return ErrPersonRelationshipNotFound + } + if err != nil { + return fmt.Errorf("find active relationship: %w", err) + } + return nil + }) + if err != nil { + return nil, err + } + return edge, nil +} + +func (s *Store) stageRelationshipReview(ctx context.Context, in RelatedImport, matchedPersonID int64, personMatched bool, actor string) (*RelationshipReview, error) { + var review *RelationshipReview + err := s.withTxContext(ctx, func(tx *loggedTx) error { + if err := s.requirePersonsExistTx(ctx, tx, in.PersonID); err != nil { + return err + } + var insertedID int64 + err := tx.QueryRowContext(ctx, ` + INSERT INTO person_relationship_reviews ( + person_id, raw_related_value, raw_related_type, value_kind, matched_person_id, + status, source, source_ref, vcard_property, vcard_group, vcard_prop_id, + vcard_pid, vcard_altid, created_by + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT DO NOTHING + RETURNING id`, + in.PersonID, in.RawValue, in.RawType, string(in.ValueKind), matchedPersonArg(matchedPersonID, personMatched), + string(RelationshipReviewPending), string(in.Source), normalizeNullableText(in.SourceRef), + nullableVCardText(in.VCardIdentity.Property), nullableVCardPointer(in.VCardIdentity.Group), + nullableVCardPointer(in.VCardIdentity.PropID), nullableVCardText(strings.Join(in.VCardIdentity.PID, ",")), + nullableVCardPointer(in.VCardIdentity.AltID), actor, + ).Scan(&insertedID) + if err == nil { + review, err = s.relationshipReviewTx(ctx, tx, insertedID) + return err + } + if !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("stage relationship review: %w", err) + } + review, err = s.relationshipReviewByOccurrenceTx(ctx, tx, in) + return err + }) + if err != nil { + return nil, err + } + return review, nil +} + +func (s *Store) ListRelationshipReviewsContext(ctx context.Context, opts RelationshipReviewListOptions) ([]RelationshipReview, error) { + query := `SELECT ` + relationshipReviewColumns + ` FROM person_relationship_reviews WHERE 1 = 1` + args := make([]any, 0, 2) + if opts.Status != "" { + query += ` AND status = ?` + args = append(args, string(opts.Status)) + } + if opts.PersonID > 0 { + query += ` AND person_id = ?` + args = append(args, opts.PersonID) + } + query += ` ORDER BY person_id, id` + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list relationship reviews: %w", err) + } + defer func() { _ = rows.Close() }() + reviews := make([]RelationshipReview, 0) + for rows.Next() { + review, scanErr := scanRelationshipReview(rows) + if scanErr != nil { + return nil, fmt.Errorf("scan relationship review: %w", scanErr) + } + reviews = append(reviews, *review) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate relationship reviews: %w", err) + } + return reviews, nil +} + +// AcceptRelationshipReviewContext claims the pending row, writes its edge, +// and records that edge ID in one transaction. Any edge failure rolls the +// conditional claim back, so review and edge can never diverge. +func (s *Store) AcceptRelationshipReviewContext(ctx context.Context, id int64, typeSlug string, targetPersonID int64, actor string) (*PersonRelationship, error) { + trimmedActor, err := validateRelationshipActor(actor) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrRelationshipReviewInvalid, err) + } + if targetPersonID <= 0 { + return nil, fmt.Errorf("%w: target person ID must be positive", ErrRelationshipReviewInvalid) + } + var edge *PersonRelationship + err = s.withTxContext(ctx, func(tx *loggedTx) error { + review, txErr := s.claimRelationshipReviewTx(ctx, tx, id, RelationshipReviewAccepted, trimmedActor) + if txErr != nil { + return txErr + } + input := PersonRelationshipInput{SourcePersonID: review.PersonID, TargetPersonID: targetPersonID, TypeSlug: typeSlug, Source: review.Source, SourceRef: review.SourceRef, VCardIdentity: review.VCardIdentity, Actor: trimmedActor} + validatedActor, notes, txErr := validatePersonRelationshipInput(input) + if txErr != nil { + return txErr + } + edge, txErr = s.addPersonRelationshipTx(ctx, tx, input, validatedActor, notes) + if txErr != nil { + return txErr + } + _, txErr = tx.ExecContext(ctx, fmt.Sprintf(` + UPDATE person_relationship_reviews + SET accepted_relationship_id = ?, updated_at = %s + WHERE id = ?`, s.dialect.Now()), edge.ID, id) + if txErr != nil { + return fmt.Errorf("record accepted relationship for review %d: %w", id, txErr) + } + return nil + }) + if err != nil { + return nil, err + } + return edge, nil +} + +// RejectRelationshipReviewContext atomically changes only a pending review. +func (s *Store) RejectRelationshipReviewContext(ctx context.Context, id int64, actor string) (*RelationshipReview, error) { + trimmedActor, err := validateRelationshipActor(actor) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrRelationshipReviewInvalid, err) + } + var rejected *RelationshipReview + err = s.withTxContext(ctx, func(tx *loggedTx) error { + var err error + rejected, err = s.claimRelationshipReviewTx(ctx, tx, id, RelationshipReviewRejected, trimmedActor) + return err + }) + if err != nil { + return nil, err + } + return rejected, nil +} + +func (s *Store) claimRelationshipReviewTx(ctx context.Context, tx *loggedTx, id int64, status RelationshipReviewStatus, actor string) (*RelationshipReview, error) { + var updatedID int64 + err := tx.QueryRowContext(ctx, fmt.Sprintf(` + UPDATE person_relationship_reviews + SET status = ?, reviewed_by = ?, reviewed_at = %s, updated_at = %s + WHERE id = ? AND status = ? + RETURNING id`, s.dialect.Now(), s.dialect.Now()), + string(status), actor, id, string(RelationshipReviewPending)).Scan(&updatedID) + if errors.Is(err, sql.ErrNoRows) { + return nil, s.relationshipReviewPendingMissTx(ctx, tx, id) + } + if err != nil { + return nil, fmt.Errorf("claim relationship review %d: %w", id, err) + } + return s.relationshipReviewTx(ctx, tx, updatedID) +} + +func (s *Store) relationshipReviewPendingMissTx(ctx context.Context, tx *loggedTx, id int64) error { + var exists int + err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM person_relationship_reviews WHERE id = ?`, id).Scan(&exists) + if err != nil { + return fmt.Errorf("check relationship review %d: %w", id, err) + } + if exists == 0 { + return fmt.Errorf("%w: id %d", ErrRelationshipReviewNotFound, id) + } + return fmt.Errorf("%w: id %d", ErrRelationshipReviewNotPending, id) +} + +const relationshipReviewColumns = ` + id, person_id, raw_related_value, raw_related_type, value_kind, + matched_person_id, status, accepted_relationship_id, source, source_ref, + vcard_property, vcard_group, vcard_prop_id, vcard_pid, vcard_altid, + created_by, reviewed_by, reviewed_at, created_at, updated_at +` + +func (s *Store) relationshipReviewTx(ctx context.Context, tx *loggedTx, id int64) (*RelationshipReview, error) { + review, err := scanRelationshipReview(tx.QueryRowContext(ctx, `SELECT `+relationshipReviewColumns+` FROM person_relationship_reviews WHERE id = ?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("%w: id %d", ErrRelationshipReviewNotFound, id) + } + if err != nil { + return nil, fmt.Errorf("get relationship review %d: %w", id, err) + } + return review, nil +} + +func (s *Store) relationshipReviewByOccurrenceTx(ctx context.Context, tx *loggedTx, in RelatedImport) (*RelationshipReview, error) { + review, err := scanRelationshipReview(tx.QueryRowContext(ctx, `SELECT `+relationshipReviewColumns+` + FROM person_relationship_reviews + WHERE person_id = ? AND raw_related_type = ? AND raw_related_value = ? AND source = ? + AND COALESCE(source_ref, '') = ? + AND COALESCE(vcard_property, '') = ? + AND COALESCE(vcard_group, '') = ? + AND COALESCE(vcard_prop_id, '') = ? + AND COALESCE(vcard_pid, '') = ? + AND COALESCE(vcard_altid, '') = ?`, + in.PersonID, in.RawType, in.RawValue, string(in.Source), + normalizedRelationshipReviewSourceRef(in.SourceRef), + in.VCardIdentity.Property, + relationshipReviewIdentityPointer(in.VCardIdentity.Group), + relationshipReviewIdentityPointer(in.VCardIdentity.PropID), + strings.Join(in.VCardIdentity.PID, ","), + relationshipReviewIdentityPointer(in.VCardIdentity.AltID), + )) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrRelationshipReviewNotFound + } + if err != nil { + return nil, fmt.Errorf("look up staged relationship review: %w", err) + } + return review, nil +} + +func normalizedRelationshipReviewSourceRef(value *string) string { + if value == nil { + return "" + } + return strings.TrimSpace(*value) +} + +func relationshipReviewIdentityPointer(value *string) string { + if value == nil { + return "" + } + return *value +} + +func matchedPersonArg(id int64, matched bool) sql.NullInt64 { + return sql.NullInt64{Int64: id, Valid: matched} +} + +func scanRelationshipReview(row scanner) (*RelationshipReview, error) { + var ( + review RelationshipReview + valueKind string + matchedID sql.NullInt64 + status string + acceptedID sql.NullInt64 + source string + sourceRef sql.NullString + property sql.NullString + group sql.NullString + propID sql.NullString + pid sql.NullString + altID sql.NullString + reviewedBy sql.NullString + reviewedAt sql.NullTime + ) + if err := row.Scan(&review.ID, &review.PersonID, &review.RawRelatedValue, &review.RawRelatedType, &valueKind, + &matchedID, &status, &acceptedID, &source, &sourceRef, &property, &group, &propID, &pid, &altID, + &review.CreatedBy, &reviewedBy, &reviewedAt, &review.CreatedAt, &review.UpdatedAt); err != nil { + return nil, err + } + review.ValueKind = RelatedValueKind(valueKind) + review.Status = RelationshipReviewStatus(status) + review.Source = Provenance(source) + if matchedID.Valid { + review.MatchedPersonID = &matchedID.Int64 + } + if acceptedID.Valid { + review.AcceptedRelationshipID = &acceptedID.Int64 + } + if sourceRef.Valid { + review.SourceRef = &sourceRef.String + } + review.VCardIdentity = scanVCardIdentity(property, group, propID, pid, altID) + if reviewedBy.Valid { + review.ReviewedBy = &reviewedBy.String + } + if reviewedAt.Valid { + review.ReviewedAt = &reviewedAt.Time + } + return &review, nil +} diff --git a/internal/store/person_relationship_related_test.go b/internal/store/person_relationship_related_test.go new file mode 100644 index 000000000..3087c4902 --- /dev/null +++ b/internal/store/person_relationship_related_test.go @@ -0,0 +1,371 @@ +package store_test + +import ( + "context" + "strings" + "sync" + "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 TestResolvePersonByVCardUIDMatchesExactlyAndDoesNotCreate(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + aliceID, _ := mustTwoPersons(t, f) + alice, err := f.Store.GetPerson(aliceID) + require.NoError(err) + + got, err := f.Store.ResolvePersonByVCardUIDContext(ctx, alice.VCardUID) + require.NoError(err) + assert.Equal(alice.ID, got.ID) + for _, uid := range []string{"", " ", strings.ToUpper(alice.VCardUID), alice.VCardUID + " ", "urn:uuid:" + alice.VCardUID, "no-such-uid"} { + _, err := f.Store.ResolvePersonByVCardUIDContext(ctx, uid) + require.ErrorIsf(err, store.ErrPersonNotFound, "UID %q must compare exactly", uid) + } + persons, err := f.Store.ListPersons() + require.NoError(err) + assert.Len(persons, 2) +} + +func TestResolveRelatedValueLinksExactUIDAndReturnsExactCanonicalDuplicate(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + aliceID, bobID := mustTwoPersons(t, f) + bob, err := f.Store.GetPerson(bobID) + require.NoError(err) + ref := "carddav-resource-1" + in := store.RelatedImport{PersonID: aliceID, RawValue: bob.VCardUID, RawType: "agent", ValueKind: store.RelatedValueKindText, Source: store.ProvenanceVCardImport, SourceRef: &ref, Actor: "system"} + first, err := f.Store.ResolveRelatedValueContext(ctx, in) + require.NoError(err) + require.NotNil(first.Relationship) + assert.Equal("agent", first.Relationship.TypeSlug) + assert.Equal(aliceID, first.Relationship.SourcePersonID) + assert.Equal(bobID, first.Relationship.TargetPersonID) + + // agent is asymmetric, so its reciprocal edge is distinct. Re-importing + // alice's assertion must find the first exact canonical triple, not search + // both orientations and accidentally return Bob's reciprocal assertion. + _, err = f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{SourcePersonID: bobID, TargetPersonID: aliceID, TypeSlug: "agent", Source: store.ProvenanceUser, Actor: "user"}) + require.NoError(err) + again, err := f.Store.ResolveRelatedValueContext(ctx, in) + require.NoError(err) + require.NotNil(again.Relationship) + assert.Equal(first.Relationship.ID, again.Relationship.ID) +} + +func TestResolveRelatedValueStagesUnresolvedAndSelfUID(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + aliceID, bobID := mustTwoPersons(t, f) + bob, err := f.Store.GetPerson(bobID) + require.NoError(err) + alice, err := f.Store.GetPerson(aliceID) + require.NoError(err) + + for _, in := range []store.RelatedImport{ + {PersonID: aliceID, RawValue: "mailto:bob@example.com", RawType: "friend", ValueKind: store.RelatedValueKindURI, Source: store.ProvenanceVCardImport, Actor: "system"}, + {PersonID: aliceID, RawValue: "tel:+12025550123", RawType: "spouse", ValueKind: store.RelatedValueKindURI, Source: store.ProvenanceVCardImport, Actor: "system"}, + {PersonID: aliceID, RawValue: "Bob from the gym", RawType: "friend", ValueKind: store.RelatedValueKindText, Source: store.ProvenanceVCardImport, Actor: "system"}, + {PersonID: aliceID, RawValue: bob.VCardUID, RawType: "work", ValueKind: store.RelatedValueKindText, Source: store.ProvenanceVCardImport, Actor: "system"}, + {PersonID: aliceID, RawValue: alice.VCardUID, RawType: "friend", ValueKind: store.RelatedValueKindText, Source: store.ProvenanceVCardImport, Actor: "system"}, + } { + resolution, resolveErr := f.Store.ResolveRelatedValueContext(ctx, in) + require.NoError(resolveErr) + require.NotNil(resolution.Review) + assert.Nil(resolution.Relationship) + if in.RawValue == bob.VCardUID { + require.NotNil(resolution.Review.MatchedPersonID) + assert.Equal(bobID, *resolution.Review.MatchedPersonID) + } + if in.RawValue == alice.VCardUID { + assert.Nil(resolution.Review.MatchedPersonID, "self UID must not violate the review self-match constraint") + } + } + views, err := f.Store.ListPersonRelationshipsContext(ctx, aliceID, store.PersonRelationshipListOptions{}) + require.NoError(err) + assert.Empty(views) +} + +func TestRelationshipReviewDeduplicatesExactPropertyOccurrencesOnly(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + aliceID, _ := mustTwoPersons(t, f) + sourceRef := " resource-1.vcf " + group := "item1" + propID := "property-1" + altID := "alternative-1" + base := store.RelatedImport{ + PersonID: aliceID, RawValue: "Bob from the gym", RawType: "friend", + ValueKind: store.RelatedValueKindText, Source: store.ProvenanceVCardImport, + SourceRef: &sourceRef, + VCardIdentity: store.VCardIdentity{ + Property: "RELATED", Group: &group, PropID: &propID, + PID: []string{"1.1", "2.1"}, AltID: &altID, + }, + Actor: "system", + } + + first, err := f.Store.ResolveRelatedValueContext(ctx, base) + require.NoError(err) + require.NotNil(first.Review) + assert.Equal("resource-1.vcf", *first.Review.SourceRef) + assert.Equal(base.VCardIdentity, first.Review.VCardIdentity) + + secondRef := "resource-2.vcf" + secondGroup := "item2" + secondPropID := "property-2" + secondAltID := "alternative-2" + secondInput := base + secondInput.SourceRef = &secondRef + secondInput.VCardIdentity = store.VCardIdentity{ + Property: "X-RELATED", Group: &secondGroup, PropID: &secondPropID, + PID: []string{"3.1", "4.1"}, AltID: &secondAltID, + } + second, err := f.Store.ResolveRelatedValueContext(ctx, secondInput) + require.NoError(err) + require.NotNil(second.Review) + assert.NotEqual(first.Review.ID, second.Review.ID) + assert.Equal(secondRef, *second.Review.SourceRef) + assert.Equal(secondInput.VCardIdentity, second.Review.VCardIdentity) + + normalizedSourceRef := "resource-1.vcf" + repeatedInput := base + repeatedInput.SourceRef = &normalizedSourceRef + repeated, err := f.Store.ResolveRelatedValueContext(ctx, repeatedInput) + require.NoError(err) + require.NotNil(repeated.Review) + assert.Equal(first.Review.ID, repeated.Review.ID) + + reviews, err := f.Store.ListRelationshipReviewsContext(ctx, store.RelationshipReviewListOptions{PersonID: aliceID}) + require.NoError(err) + assert.Len(reviews, 2) +} + +func TestResolveRelatedValueAcceptsUrnUUIDCaseInsensitively(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + aliceID, bobID := mustTwoPersons(t, f) + bob, err := f.Store.GetPerson(bobID) + require.NoError(err) + resolution, err := f.Store.ResolveRelatedValueContext(ctx, store.RelatedImport{PersonID: aliceID, RawValue: "URN:UUID:" + strings.ToUpper(bob.VCardUID), RawType: "sibling", ValueKind: store.RelatedValueKindURI, Source: store.ProvenanceVCardImport, Actor: "system"}) + require.NoError(err) + require.NotNil(resolution.Relationship) + + for _, rawValue := range []string{ + "urn:uuid:{" + strings.ToUpper(bob.VCardUID) + "}", + "urn:uuid:" + strings.ReplaceAll(strings.ToUpper(bob.VCardUID), "-", ""), + } { + staged, stageErr := f.Store.ResolveRelatedValueContext(ctx, store.RelatedImport{ + PersonID: aliceID, RawValue: rawValue, RawType: "friend", + ValueKind: store.RelatedValueKindURI, Source: store.ProvenanceVCardImport, Actor: "system", + }) + require.NoError(stageErr) + require.NotNil(staged.Review, "only canonical hyphenated UUID URNs may auto-match") + assert.Nil(staged.Relationship) + assert.Nil(staged.Review.MatchedPersonID) + } +} + +func TestRelationshipReviewPreservesIdentityAndAcceptsAtomically(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + aliceID, bobID := mustTwoPersons(t, f) + identity := store.VCardIdentity{Property: "RELATED", Group: new("item1"), PropID: new("p3"), PID: []string{"1.1", "2.1"}, AltID: new("a2")} + staged, err := f.Store.ResolveRelatedValueContext(ctx, store.RelatedImport{PersonID: aliceID, RawValue: "Bob from the gym", RawType: "arch-nemesis", ValueKind: store.RelatedValueKindText, Source: store.ProvenanceCardDAVImport, VCardIdentity: identity, Actor: "system"}) + require.NoError(err) + require.NotNil(staged.Review) + assert.Equal(identity, staged.Review.VCardIdentity) + + edge, err := f.Store.AcceptRelationshipReviewContext(ctx, staged.Review.ID, "acquaintance", bobID, "user") + require.NoError(err) + assert.Equal(identity, edge.VCardIdentity) + reloaded, err := f.Store.GetPersonRelationshipContext(ctx, edge.ID) + require.NoError(err) + assert.Equal(identity, reloaded.VCardIdentity) + + _, err = f.Store.AcceptRelationshipReviewContext(ctx, staged.Review.ID, "acquaintance", bobID, "user") + require.ErrorIs(err, store.ErrRelationshipReviewNotPending) +} + +func TestRelationshipReviewAcceptRollbackAndRejectDurability(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + aliceID, bobID := mustTwoPersons(t, f) + bob, err := f.Store.GetPerson(bobID) + require.NoError(err) + in := store.RelatedImport{PersonID: aliceID, RawValue: bob.VCardUID, RawType: "unknown", ValueKind: store.RelatedValueKindText, Source: store.ProvenanceVCardImport, Actor: "system"} + staged, err := f.Store.ResolveRelatedValueContext(ctx, in) + require.NoError(err) + require.NotNil(staged.Review) + _, err = f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{SourcePersonID: aliceID, TargetPersonID: bobID, TypeSlug: "friend", Source: store.ProvenanceUser, Actor: "user"}) + require.NoError(err) + _, err = f.Store.AcceptRelationshipReviewContext(ctx, staged.Review.ID, "friend", bobID, "user") + require.ErrorIs(err, store.ErrPersonRelationshipDuplicate) + reviews, err := f.Store.ListRelationshipReviewsContext(ctx, store.RelationshipReviewListOptions{}) + require.NoError(err) + require.Len(reviews, 1) + assert.Equal(store.RelationshipReviewPending, reviews[0].Status, "a failed edge write must roll back the claimed review") + + rejected, err := f.Store.RejectRelationshipReviewContext(ctx, staged.Review.ID, "user") + require.NoError(err) + assert.Equal(store.RelationshipReviewRejected, rejected.Status) + again, err := f.Store.ResolveRelatedValueContext(ctx, in) + require.NoError(err) + require.NotNil(again.Review) + assert.Equal(staged.Review.ID, again.Review.ID) + assert.Equal(store.RelationshipReviewRejected, again.Status()) + _, err = f.Store.RejectRelationshipReviewContext(ctx, staged.Review.ID, "user") + require.ErrorIs(err, store.ErrRelationshipReviewNotPending) +} + +func TestRelationshipReviewConcurrentAcceptAndRejectHaveOneWinner(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + aliceID, bobID := mustTwoPersons(t, f) + staged, err := f.Store.ResolveRelatedValueContext(ctx, store.RelatedImport{ + PersonID: aliceID, RawValue: "Bob from the gym", RawType: "unknown", + ValueKind: store.RelatedValueKindText, Source: store.ProvenanceVCardImport, Actor: "system", + }) + require.NoError(err) + require.NotNil(staged.Review) + + type outcome struct { + operation string + err error + } + start := make(chan struct{}) + results := make(chan outcome, 2) + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + <-start + _, acceptErr := f.Store.AcceptRelationshipReviewContext(ctx, staged.Review.ID, "friend", bobID, "user") + results <- outcome{operation: "accept", err: acceptErr} + }() + go func() { + defer wg.Done() + <-start + _, rejectErr := f.Store.RejectRelationshipReviewContext(ctx, staged.Review.ID, "user") + results <- outcome{operation: "reject", err: rejectErr} + }() + close(start) + wg.Wait() + close(results) + + var successes, notPending int + for result := range results { + if result.err == nil { + successes++ + continue + } + require.ErrorIsf(result.err, store.ErrRelationshipReviewNotPending, "%s loses only because the review is no longer pending", result.operation) + notPending++ + } + assert.Equal(1, successes) + assert.Equal(1, notPending) + reviews, err := f.Store.ListRelationshipReviewsContext(ctx, store.RelationshipReviewListOptions{}) + require.NoError(err) + require.Len(reviews, 1) + if reviews[0].Status == store.RelationshipReviewAccepted { + require.NotNil(reviews[0].AcceptedRelationshipID) + _, err = f.Store.GetPersonRelationshipContext(ctx, *reviews[0].AcceptedRelationshipID) + require.NoError(err) + return + } + assert.Equal(store.RelationshipReviewRejected, reviews[0].Status) + assert.Nil(reviews[0].AcceptedRelationshipID) +} + +func TestResolveRelatedValueRejectsUnsplitTypeListsAndInvalidValues(t *testing.T) { + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + aliceID, _ := mustTwoPersons(t, f) + for _, in := range []store.RelatedImport{ + {PersonID: aliceID, RawValue: "someone", RawType: "friend,co-worker", ValueKind: store.RelatedValueKindText, Source: store.ProvenanceVCardImport, Actor: "system"}, + {PersonID: aliceID, RawValue: strings.Repeat("x", 1025), RawType: "friend", ValueKind: store.RelatedValueKindText, Source: store.ProvenanceVCardImport, Actor: "system"}, + {PersonID: aliceID, RawValue: " ", RawType: "friend", ValueKind: store.RelatedValueKindText, Source: store.ProvenanceVCardImport, Actor: "system"}, + {PersonID: aliceID, RawValue: "someone", RawType: "friend", ValueKind: store.RelatedValueKind("binary"), Source: store.ProvenanceVCardImport, Actor: "system"}, + } { + _, err := f.Store.ResolveRelatedValueContext(ctx, in) + require.ErrorIs(err, store.ErrRelationshipReviewInvalid) + } +} + +func TestRelationshipReviewRejectsOversizedActorsWithoutStateChanges(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + aliceID, _ := mustTwoPersons(t, f) + invalidActors := []string{strings.Repeat("x", 129), " "} + for _, invalidActor := range invalidActors { + _, err := f.Store.ResolveRelatedValueContext(ctx, store.RelatedImport{ + PersonID: aliceID, RawValue: "Bob from the gym", RawType: "friend", + ValueKind: store.RelatedValueKindText, Source: store.ProvenanceVCardImport, Actor: invalidActor, + }) + require.ErrorIs(err, store.ErrRelationshipReviewInvalid) + } + reviews, err := f.Store.ListRelationshipReviewsContext(ctx, store.RelationshipReviewListOptions{}) + require.NoError(err) + assert.Empty(reviews, "a rejected resolve input must not stage a row") + + staged, err := f.Store.ResolveRelatedValueContext(ctx, store.RelatedImport{ + PersonID: aliceID, RawValue: "Bob from the gym", RawType: "friend", + ValueKind: store.RelatedValueKindText, Source: store.ProvenanceVCardImport, Actor: "system", + }) + require.NoError(err) + require.NotNil(staged.Review) + for _, invalidActor := range invalidActors { + _, err = f.Store.RejectRelationshipReviewContext(ctx, staged.Review.ID, invalidActor) + require.ErrorIs(err, store.ErrRelationshipReviewInvalid) + } + reviews, err = f.Store.ListRelationshipReviewsContext(ctx, store.RelationshipReviewListOptions{}) + require.NoError(err) + require.Len(reviews, 1) + assert.Equal(store.RelationshipReviewPending, reviews[0].Status) + assert.Nil(reviews[0].ReviewedBy) + assert.Nil(reviews[0].ReviewedAt) +} + +func TestRelationshipReviewDatabaseConstraintsAndColumns(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + aliceID, bobID := mustTwoPersons(t, f) + _, err := f.Store.DB().Exec(f.Store.Rebind(`INSERT INTO person_relationship_reviews (person_id, raw_related_value, raw_related_type, value_kind, source) VALUES (?, 'bad-source', 'friend', 'text', 'guess')`), aliceID) + require.Error(err, "the review source vocabulary must be enforced by the live database") + _, err = f.Store.DB().Exec(f.Store.Rebind(`INSERT INTO person_relationship_reviews (person_id, raw_related_value, raw_related_type, value_kind, matched_person_id, source) VALUES (?, 'self', 'friend', 'text', ?, 'system')`), aliceID, aliceID) + require.Error(err, "self matches are structurally forbidden") + _, err = f.Store.DB().Exec(f.Store.Rebind(`INSERT INTO person_relationship_reviews (person_id, raw_related_value, raw_related_type, value_kind, matched_person_id, source) VALUES (?, 'valid', 'friend', 'text', ?, 'system')`), aliceID, bobID) + require.NoError(err) + + rows, err := f.Store.DB().Query(`SELECT * FROM person_relationship_reviews WHERE 1 = 0`) + require.NoError(err) + defer func() { require.NoError(rows.Close()) }() + columns, err := rows.Columns() + require.NoError(err) + require.NoError(rows.Err()) + assert.ElementsMatch([]string{"id", "person_id", "raw_related_value", "raw_related_type", "value_kind", "matched_person_id", "status", "accepted_relationship_id", "source", "source_ref", "vcard_property", "vcard_group", "vcard_prop_id", "vcard_pid", "vcard_altid", "created_by", "reviewed_by", "reviewed_at", "created_at", "updated_at"}, columns) +} diff --git a/internal/store/person_relationship_views_test.go b/internal/store/person_relationship_views_test.go new file mode 100644 index 000000000..de8e4b692 --- /dev/null +++ b/internal/store/person_relationship_views_test.go @@ -0,0 +1,244 @@ +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 mustPerson(t *testing.T, f *storetest.Fixture, email, username string) int64 { + t.Helper() + participant := f.EnsureParticipant(email, username, "example.com") + person, _, err := f.Store.CreatePersonFromParticipant(participant) + require.NoError(t, err) + _, err = f.Store.UpdatePersonDisplayNameContext(t.Context(), person.ID, person.Revision, &username) + require.NoError(t, err) + return person.ID +} + +func mustSetPersonDisplayName(t *testing.T, f *storetest.Fixture, personID int64, displayName string) { + t.Helper() + person, err := f.Store.GetPersonContext(t.Context(), personID) + require.NoError(t, err) + _, err = f.Store.UpdatePersonDisplayNameContext(t.Context(), personID, person.Revision, &displayName) + require.NoError(t, err) +} + +func TestListPersonRelationshipsRendersBothDirectionsFromOneRow(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + alice, bob := mustTwoPersons(t, f) + mustSetPersonDisplayName(t, f, alice, "alice") + + // "alice is the parent of bob" — stored once. + edge, err := f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: alice, TargetPersonID: bob, TypeSlug: "parent", + Source: store.ProvenanceUser, Actor: "user", + }) + require.NoError(err) + + fromAlice, err := f.Store.ListPersonRelationshipsContext(ctx, alice, + store.PersonRelationshipListOptions{}) + require.NoError(err) + require.Len(fromAlice, 1) + assert.Equal(edge.ID, fromAlice[0].Relationship.ID) + assert.Equal(store.RelationshipDirectionOutgoing, fromAlice[0].Direction) + assert.Equal(bob, fromAlice[0].CounterpartPersonID) + assert.Equal("child", fromAlice[0].CounterpartLabel, "bob is alice's child") + + fromBob, err := f.Store.ListPersonRelationshipsContext(ctx, bob, + store.PersonRelationshipListOptions{}) + require.NoError(err) + require.Len(fromBob, 1) + assert.Equal(edge.ID, fromBob[0].Relationship.ID, "the same single row serves both views") + assert.Equal(store.RelationshipDirectionIncoming, fromBob[0].Direction) + assert.Equal(alice, fromBob[0].CounterpartPersonID) + assert.Equal("parent", fromBob[0].CounterpartLabel, "alice is bob's parent") + + // The counterpart identity comes along so a caller needs no second query. + require.NotNil(fromBob[0].CounterpartDisplayName) + assert.Equal("alice", *fromBob[0].CounterpartDisplayName) + assert.NotEmpty(fromBob[0].CounterpartVCardUID) +} + +func TestListPersonRelationshipsRendersSymmetricTypesIdenticallyFromBothEnds(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + alice, bob := mustTwoPersons(t, f) + + _, err := f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: alice, TargetPersonID: bob, TypeSlug: "spouse", + Source: store.ProvenanceUser, Actor: "user", + }) + require.NoError(err) + + fromAlice, err := f.Store.ListPersonRelationshipsContext(ctx, alice, + store.PersonRelationshipListOptions{}) + require.NoError(err) + require.Len(fromAlice, 1) + fromBob, err := f.Store.ListPersonRelationshipsContext(ctx, bob, + store.PersonRelationshipListOptions{}) + require.NoError(err) + require.Len(fromBob, 1) + + assert.Equal("spouse", fromAlice[0].CounterpartLabel) + assert.Equal("spouse", fromBob[0].CounterpartLabel) + assert.Equal(fromAlice[0].Relationship.ID, fromBob[0].Relationship.ID) + assert.Equal(bob, fromAlice[0].CounterpartPersonID) + assert.Equal(alice, fromBob[0].CounterpartPersonID) +} + +func TestListPersonRelationshipsHidesEndedEdgesUnlessAsked(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + alice, bob := mustTwoPersons(t, f) + + ending, err := f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: alice, TargetPersonID: bob, TypeSlug: "co-worker", + Source: store.ProvenanceUser, Actor: "user", + }) + require.NoError(err) + until, err := store.ParseRelationshipDate("2022-01") + require.NoError(err) + _, err = f.Store.EndPersonRelationshipContext(ctx, ending.ID, ending.Revision, until, "user") + require.NoError(err) + + current, err := f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: alice, TargetPersonID: bob, TypeSlug: "friend", + Source: store.ProvenanceUser, Actor: "user", + }) + require.NoError(err) + + active, err := f.Store.ListPersonRelationshipsContext(ctx, alice, + store.PersonRelationshipListOptions{}) + require.NoError(err) + require.Len(active, 1) + assert.Equal(current.ID, active[0].Relationship.ID) + + all, err := f.Store.ListPersonRelationshipsContext(ctx, alice, + store.PersonRelationshipListOptions{IncludeEnded: true}) + require.NoError(err) + require.Len(all, 2) + // Active edges sort ahead of history. + assert.Equal(store.RelationshipStatusActive, all[0].Relationship.Status) + assert.Equal(store.RelationshipStatusEnded, all[1].Relationship.Status) + require.NotNil(all[1].Relationship.EndDate) + assert.Equal("2022-01", all[1].Relationship.EndDate.String()) +} + +func TestListPersonRelationshipsIsDeterministicAndScopedToOnePerson(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + alice := mustPerson(t, f, "alice@example.com", "alice") + bob := mustPerson(t, f, "bob@example.com", "bob") + carol := mustPerson(t, f, "carol@example.com", "carol") + + for _, target := range []int64{carol, bob} { + _, err := f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: alice, TargetPersonID: target, TypeSlug: "friend", + Source: store.ProvenanceUser, Actor: "user", + }) + require.NoError(err) + } + _, err := f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: bob, TargetPersonID: carol, TypeSlug: "neighbor", + Source: store.ProvenanceUser, Actor: "user", + }) + require.NoError(err) + + views, err := f.Store.ListPersonRelationshipsContext(ctx, alice, + store.PersonRelationshipListOptions{}) + require.NoError(err) + require.Len(views, 2, "bob<->carol is not alice's relationship") + // Ordered by counterpart display name, so the result is stable across runs. + assert.Equal(bob, views[0].CounterpartPersonID) + assert.Equal(carol, views[1].CounterpartPersonID) + + empty, err := f.Store.ListPersonRelationshipsContext(ctx, 999999, + store.PersonRelationshipListOptions{}) + require.NoError(err) + assert.Empty(empty, "an unknown person has no relationships, not an error") +} + +func TestListPersonRelationshipsCountsEachEdgeOnceForOnePerson(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + ctx := context.Background() + alice, bob := mustTwoPersons(t, f) + + // Mutual asymmetric edges are two distinct rows; each must appear once, + // in the direction that person occupies. + _, err := f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: alice, TargetPersonID: bob, TypeSlug: "agent", + Source: store.ProvenanceUser, Actor: "user", + }) + require.NoError(err) + _, err = f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: bob, TargetPersonID: alice, TypeSlug: "agent", + Source: store.ProvenanceUser, Actor: "user", + }) + require.NoError(err) + + views, err := f.Store.ListPersonRelationshipsContext(ctx, alice, + store.PersonRelationshipListOptions{}) + require.NoError(err) + require.Len(views, 2) + directions := []store.RelationshipDirection{views[0].Direction, views[1].Direction} + assert.Contains(directions, store.RelationshipDirectionOutgoing) + assert.Contains(directions, store.RelationshipDirectionIncoming) + labels := []string{views[0].CounterpartLabel, views[1].CounterpartLabel} + assert.Contains(labels, "principal", "alice is bob's agent, so bob is the principal") + assert.Contains(labels, "agent", "bob is alice's agent") +} + +func TestCounterpartLabelHelperMatchesTheQueryProjection(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + alice, bob := mustTwoPersons(t, f) + + for _, slug := range []string{"parent", "agent", "emergency", "muse", "crush", "friend"} { + relationshipType, err := f.Store.GetRelationshipTypeBySlugContext(ctx, slug) + require.NoError(err) + assert.Equalf(relationshipType.ReverseLabel, + relationshipType.CounterpartLabel(store.RelationshipDirectionOutgoing), + "type %q outgoing label", slug) + assert.Equalf(relationshipType.ForwardLabel, + relationshipType.CounterpartLabel(store.RelationshipDirectionIncoming), + "type %q incoming label", slug) + } + + edge, err := f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: alice, TargetPersonID: bob, TypeSlug: "emergency", + Source: store.ProvenanceUser, Actor: "user", + }) + require.NoError(err) + relationshipType, err := f.Store.GetRelationshipTypeContext(ctx, edge.RelationshipTypeID) + require.NoError(err) + + for personID, direction := range map[int64]store.RelationshipDirection{ + alice: store.RelationshipDirectionOutgoing, + bob: store.RelationshipDirectionIncoming, + } { + views, err := f.Store.ListPersonRelationshipsContext(ctx, personID, + store.PersonRelationshipListOptions{}) + require.NoError(err) + require.Len(views, 1) + assert.Equal(direction, views[0].Direction) + assert.Equal(relationshipType.CounterpartLabel(direction), views[0].CounterpartLabel) + } +} diff --git a/internal/store/person_relationships.go b/internal/store/person_relationships.go new file mode 100644 index 000000000..cf6ad8670 --- /dev/null +++ b/internal/store/person_relationships.go @@ -0,0 +1,673 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "math" + "strings" + "time" +) + +var ( + ErrPersonRelationshipNotFound = errors.New("person relationship not found") + ErrPersonRelationshipRevisionConflict = errors.New("person relationship revision conflict") + ErrPersonRelationshipDuplicate = errors.New("an active relationship of this type already exists") + ErrPersonRelationshipSelf = errors.New("a person cannot have a relationship with itself") + ErrPersonRelationshipInvalid = errors.New("invalid person relationship") +) + +const ( + maxRelationshipNotesBytes = 4096 + maxRelationshipActorBytes = 128 +) + +// RelationshipStatus is derived from the world-time interval and never comes +// from a caller, so it cannot disagree with the edge's end bound. +type RelationshipStatus string + +const ( + RelationshipStatusActive RelationshipStatus = "active" + RelationshipStatusEnded RelationshipStatus = "ended" +) + +func statusForInterval(endDate *PartialDate) RelationshipStatus { + if endDate == nil { + return RelationshipStatusActive + } + return RelationshipStatusEnded +} + +// PersonRelationship is one canonical edge joined to the presentation fields +// of its relationship type. +type PersonRelationship struct { + ID int64 `json:"id"` + SourcePersonID int64 `json:"source_person_id"` + TargetPersonID int64 `json:"target_person_id"` + RelationshipTypeID int64 `json:"relationship_type_id"` + TypeSlug string `json:"type_slug"` + ForwardLabel string `json:"forward_label"` + ReverseLabel string `json:"reverse_label"` + IsSymmetric bool `json:"is_symmetric"` + StartDate *PartialDate `json:"start_date,omitempty"` + EndDate *PartialDate `json:"end_date,omitempty"` + Status RelationshipStatus `json:"status"` + Notes *string `json:"notes,omitempty"` + Source Provenance `json:"source"` + SourceRef *string `json:"source_ref,omitempty"` + Confidence *float64 `json:"confidence,omitempty"` + VCardIdentity VCardIdentity `json:"vcard_identity"` + CreatedBy string `json:"created_by"` + UpdatedBy string `json:"updated_by"` + Revision int64 `json:"revision"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// PersonRelationshipInput declares one relationship. Status is derived from +// EndDate rather than accepted as mutable caller input. +type PersonRelationshipInput struct { + SourcePersonID int64 + TargetPersonID int64 + TypeSlug string + StartDate *PartialDate + EndDate *PartialDate + Notes *string + Source Provenance + SourceRef *string + Confidence *float64 + VCardIdentity VCardIdentity + Actor string +} + +// PersonRelationshipPatch replaces one or both mutable edge values through a +// single revision-qualified update. The boolean fields distinguish an omitted +// value from an explicit request to clear notes. +type PersonRelationshipPatch struct { + EndDate *PartialDate + UpdateEndDate bool + Notes *string + UpdateNotes bool +} + +const personRelationshipColumns = ` + r.id, r.source_person_id, r.target_person_id, r.relationship_type_id, + t.slug, t.forward_label, t.reverse_label, t.is_symmetric, + r.start_year, r.start_month, r.start_day, + r.end_year, r.end_month, r.end_day, r.status, r.notes, + r.source, r.source_ref, r.confidence, + r.vcard_property, r.vcard_group, r.vcard_prop_id, r.vcard_pid, r.vcard_altid, + r.created_by, r.updated_by, r.revision, r.created_at, r.updated_at +` + +const personRelationshipFrom = ` + FROM person_relationships r + JOIN relationship_types t ON t.id = r.relationship_type_id +` + +// AddPersonRelationshipContext stores one edge in canonical orientation. +// A non-canonical inverse type first swaps endpoints and resolves to its +// canonical type; then a symmetric canonical type orders endpoints by ID. +func (s *Store) AddPersonRelationshipContext( + ctx context.Context, input PersonRelationshipInput, +) (*PersonRelationship, error) { + actor, notes, err := validatePersonRelationshipInput(input) + if err != nil { + return nil, err + } + var created *PersonRelationship + err = s.withTxContext(ctx, func(tx *loggedTx) error { + var txErr error + created, txErr = s.addPersonRelationshipTx(ctx, tx, input, actor, notes) + return txErr + }) + if err != nil { + return nil, err + } + return created, nil +} + +func validatePersonRelationshipInput(input PersonRelationshipInput) (string, sql.NullString, error) { + if _, err := ParseProvenance(string(input.Source)); err != nil { + return "", sql.NullString{}, err + } + actor, err := validateRelationshipActor(input.Actor) + if err != nil { + return "", sql.NullString{}, err + } + if input.SourcePersonID <= 0 || input.TargetPersonID <= 0 { + return "", sql.NullString{}, fmt.Errorf("%w: person IDs must be positive", ErrPersonRelationshipInvalid) + } + if input.SourcePersonID == input.TargetPersonID { + return "", sql.NullString{}, fmt.Errorf("%w: person %d", ErrPersonRelationshipSelf, input.SourcePersonID) + } + if err := validateRelationshipInterval(input.StartDate, input.EndDate); err != nil { + return "", sql.NullString{}, err + } + if err := validateRelationshipConfidence(input.Source, input.Confidence); err != nil { + return "", sql.NullString{}, err + } + notes, err := normalizeRelationshipNotes(input.Notes) + if err != nil { + return "", sql.NullString{}, err + } + return actor, notes, nil +} + +// addPersonRelationshipTx is the transaction-scoped form used by both the +// public add method and review acceptance. Its caller must validate input +// through validatePersonRelationshipInput before entering the transaction. +func (s *Store) addPersonRelationshipTx( + ctx context.Context, tx *loggedTx, input PersonRelationshipInput, actor string, notes sql.NullString, +) (*PersonRelationship, error) { + relationshipType, sourceID, targetID, err := s.canonicalRelationshipEndpointsTx(ctx, tx, input.SourcePersonID, input.TargetPersonID, input.TypeSlug) + if err != nil { + return nil, err + } + if err := s.requirePersonsExistTx(ctx, tx, sourceID, targetID); err != nil { + return nil, err + } + + args := []any{sourceID, targetID, relationshipType.ID} + args = append(args, relationshipDateArgs(input.StartDate)...) + args = append(args, relationshipDateArgs(input.EndDate)...) + args = append(args, + string(statusForInterval(input.EndDate)), notes, + string(input.Source), normalizeNullableText(input.SourceRef), confidenceArg(input.Confidence), + nullableVCardText(input.VCardIdentity.Property), + nullableVCardPointer(input.VCardIdentity.Group), + nullableVCardPointer(input.VCardIdentity.PropID), + nullableVCardText(strings.Join(input.VCardIdentity.PID, ",")), + nullableVCardPointer(input.VCardIdentity.AltID), actor, actor, + ) + + var insertedID int64 + err = tx.QueryRowContext(ctx, ` + INSERT INTO person_relationships ( + source_person_id, target_person_id, relationship_type_id, + start_year, start_month, start_day, end_year, end_month, end_day, + status, notes, source, source_ref, confidence, + vcard_property, vcard_group, vcard_prop_id, vcard_pid, vcard_altid, + created_by, updated_by + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + RETURNING id + `, args...).Scan(&insertedID) + if err != nil { + if s.dialect.IsConflictError(err) { + return nil, fmt.Errorf("%w: %d -> %d as %q", ErrPersonRelationshipDuplicate, sourceID, targetID, relationshipType.Slug) + } + return nil, fmt.Errorf("add person relationship: %w", err) + } + return s.personRelationshipTx(ctx, tx, insertedID) +} + +func (s *Store) canonicalRelationshipEndpointsTx( + ctx context.Context, tx *loggedTx, sourceID, targetID int64, typeSlug string, +) (*RelationshipType, int64, int64, error) { + relationshipType, err := s.relationshipTypeBySlugTx(ctx, tx, typeSlug) + if err != nil { + return nil, 0, 0, err + } + if !relationshipType.IsCanonical { + if relationshipType.InverseTypeID == nil { + return nil, 0, 0, fmt.Errorf("%w: type %q is non-canonical without an inverse", ErrPersonRelationshipInvalid, relationshipType.Slug) + } + sourceID, targetID = targetID, sourceID + relationshipType, err = s.relationshipTypeByIDTx(ctx, tx, *relationshipType.InverseTypeID) + if err != nil { + return nil, 0, 0, err + } + } + if relationshipType.IsSymmetric && sourceID > targetID { + sourceID, targetID = targetID, sourceID + } + return relationshipType, sourceID, targetID, nil +} + +func (s *Store) GetPersonRelationshipContext(ctx context.Context, id int64) (*PersonRelationship, error) { + var edge *PersonRelationship + err := s.withTxContext(ctx, func(tx *loggedTx) error { + var txErr error + edge, txErr = s.personRelationshipTx(ctx, tx, id) + return txErr + }) + if err != nil { + return nil, err + } + return edge, nil +} + +// EndPersonRelationshipContext closes a world-time interval with revision +// compare-and-swap, retaining the historical edge. +func (s *Store) EndPersonRelationshipContext( + ctx context.Context, id, expectedRevision int64, until PartialDate, actor string, +) (*PersonRelationship, error) { + return s.PatchPersonRelationshipContext(ctx, id, expectedRevision, + PersonRelationshipPatch{EndDate: &until, UpdateEndDate: true}, actor) +} + +func (s *Store) UpdatePersonRelationshipNotesContext( + ctx context.Context, id, expectedRevision int64, notes *string, actor string, +) (*PersonRelationship, error) { + return s.PatchPersonRelationshipContext(ctx, id, expectedRevision, + PersonRelationshipPatch{Notes: notes, UpdateNotes: true}, actor) +} + +// PatchPersonRelationshipContext atomically ends an edge and/or replaces its +// notes. All supplied values and the actor are validated before it begins its +// one transaction; a single revision-qualified UPDATE applies every change +// and increments the revision once. +func (s *Store) PatchPersonRelationshipContext( + ctx context.Context, id, expectedRevision int64, patch PersonRelationshipPatch, actor string, +) (*PersonRelationship, error) { + if !patch.UpdateEndDate && !patch.UpdateNotes { + return nil, fmt.Errorf("%w: end date or notes is required", ErrPersonRelationshipInvalid) + } + if patch.UpdateEndDate { + if patch.EndDate == nil { + return nil, fmt.Errorf("%w: end date is required", ErrPersonRelationshipInvalid) + } + if err := validateRelationshipBound(patch.EndDate); err != nil { + return nil, err + } + } + var notes sql.NullString + var err error + if patch.UpdateNotes { + notes, err = normalizeRelationshipNotes(patch.Notes) + if err != nil { + return nil, err + } + } + trimmedActor, err := validateRelationshipActor(actor) + if err != nil { + return nil, err + } + var updated *PersonRelationship + err = s.withTxContext(ctx, func(tx *loggedTx) error { + if patch.UpdateEndDate { + current, txErr := s.personRelationshipTx(ctx, tx, id) + if txErr != nil { + return txErr + } + if txErr = validateRelationshipInterval(current.StartDate, patch.EndDate); txErr != nil { + return txErr + } + } + var updatedID int64 + var txErr error + switch { + case patch.UpdateEndDate && patch.UpdateNotes: + args := append(relationshipDateArgs(patch.EndDate), string(RelationshipStatusEnded), notes, + trimmedActor, id, expectedRevision) + txErr = tx.QueryRowContext(ctx, fmt.Sprintf(` + UPDATE person_relationships + SET end_year = ?, end_month = ?, end_day = ?, status = ?, notes = ?, updated_by = ?, + revision = revision + 1, updated_at = %s + WHERE id = ? AND revision = ? + RETURNING id + `, s.dialect.Now()), args...).Scan(&updatedID) + case patch.UpdateEndDate: + args := append(relationshipDateArgs(patch.EndDate), string(RelationshipStatusEnded), trimmedActor, + id, expectedRevision) + txErr = tx.QueryRowContext(ctx, fmt.Sprintf(` + UPDATE person_relationships + SET end_year = ?, end_month = ?, end_day = ?, status = ?, updated_by = ?, + revision = revision + 1, updated_at = %s + WHERE id = ? AND revision = ? + RETURNING id + `, s.dialect.Now()), args...).Scan(&updatedID) + default: + txErr = tx.QueryRowContext(ctx, fmt.Sprintf(` + UPDATE person_relationships + SET notes = ?, updated_by = ?, revision = revision + 1, updated_at = %s + WHERE id = ? AND revision = ? + RETURNING id + `, s.dialect.Now()), notes, trimmedActor, id, expectedRevision).Scan(&updatedID) + } + if errors.Is(txErr, sql.ErrNoRows) { + return s.personRelationshipCASMissTx(ctx, tx, id) + } + if txErr != nil { + return fmt.Errorf("patch person relationship %d: %w", id, txErr) + } + updated, txErr = s.personRelationshipTx(ctx, tx, updatedID) + return txErr + }) + if err != nil { + return nil, err + } + return updated, nil +} + +func (s *Store) DeletePersonRelationshipContext(ctx context.Context, id, expectedRevision int64) error { + return s.withTxContext(ctx, func(tx *loggedTx) error { + var deletedID int64 + err := tx.QueryRowContext(ctx, + `DELETE FROM person_relationships WHERE id = ? AND revision = ? RETURNING id`, id, expectedRevision, + ).Scan(&deletedID) + if errors.Is(err, sql.ErrNoRows) { + return s.personRelationshipCASMissTx(ctx, tx, id) + } + if err != nil { + return fmt.Errorf("delete person relationship %d: %w", id, err) + } + return nil + }) +} + +func (s *Store) personRelationshipCASMissTx(ctx context.Context, tx *loggedTx, id int64) error { + var revision int64 + err := tx.QueryRowContext(ctx, `SELECT revision FROM person_relationships WHERE id = ?`, id).Scan(&revision) + if errors.Is(err, sql.ErrNoRows) { + return ErrPersonRelationshipNotFound + } + if err != nil { + return fmt.Errorf("check person relationship %d after revision miss: %w", id, err) + } + return ErrPersonRelationshipRevisionConflict +} + +func (s *Store) personRelationshipTx(ctx context.Context, tx *loggedTx, id int64) (*PersonRelationship, error) { + edge, err := scanPersonRelationship(tx.QueryRowContext(ctx, + `SELECT `+personRelationshipColumns+personRelationshipFrom+` WHERE r.id = ?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrPersonRelationshipNotFound + } + if err != nil { + return nil, fmt.Errorf("get person relationship %d: %w", id, err) + } + return edge, nil +} + +func (s *Store) relationshipTypeBySlugTx(ctx context.Context, tx *loggedTx, slug string) (*RelationshipType, error) { + trimmed := strings.TrimSpace(slug) + relationshipType, err := scanRelationshipType(tx.QueryRowContext(ctx, + `SELECT `+relationshipTypeColumns+` FROM relationship_types WHERE slug = ?`, trimmed)) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("%w: %q", ErrRelationshipTypeNotFound, trimmed) + } + if err != nil { + return nil, fmt.Errorf("load relationship type %q: %w", trimmed, err) + } + return relationshipType, nil +} + +func (s *Store) relationshipTypeByIDTx(ctx context.Context, tx *loggedTx, id int64) (*RelationshipType, error) { + relationshipType, err := scanRelationshipType(tx.QueryRowContext(ctx, + `SELECT `+relationshipTypeColumns+` FROM relationship_types WHERE id = ?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("%w: id %d", ErrRelationshipTypeNotFound, id) + } + if err != nil { + return nil, fmt.Errorf("load relationship type %d: %w", id, err) + } + return relationshipType, nil +} + +func (s *Store) requirePersonsExistTx(ctx context.Context, tx *loggedTx, ids ...int64) error { + for _, id := range ids { + var exists int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM persons WHERE id = ?`, id).Scan(&exists); err != nil { + return fmt.Errorf("verify person %d: %w", id, err) + } + if exists == 0 { + return fmt.Errorf("%w: id %d", ErrPersonNotFound, id) + } + } + return nil +} + +func validateRelationshipConfidence(source Provenance, confidence *float64) error { + if confidence == nil { + return nil + } + if source.IsDeclared() { + return fmt.Errorf("%w: declared provenance %q must not carry a confidence score", ErrConfidenceScope, source) + } + if math.IsNaN(*confidence) || *confidence < 0 || *confidence > 1 { + return fmt.Errorf("%w: confidence %v is outside [0,1]", ErrConfidenceScope, *confidence) + } + return nil +} + +func validateRelationshipActor(actor string) (string, error) { + trimmed := strings.TrimSpace(actor) + if trimmed == "" { + return "", fmt.Errorf("%w: actor is required", ErrPersonRelationshipInvalid) + } + if len(trimmed) > maxRelationshipActorBytes { + return "", fmt.Errorf("%w: actor exceeds %d bytes", ErrPersonRelationshipInvalid, maxRelationshipActorBytes) + } + return trimmed, nil +} + +func normalizeRelationshipNotes(notes *string) (sql.NullString, error) { + if notes == nil { + return sql.NullString{}, nil + } + trimmed := strings.TrimSpace(*notes) + if trimmed == "" { + return sql.NullString{}, nil + } + if len(trimmed) > maxRelationshipNotesBytes { + return sql.NullString{}, fmt.Errorf("%w: notes exceed %d bytes", ErrPersonRelationshipInvalid, maxRelationshipNotesBytes) + } + return sql.NullString{String: trimmed, Valid: true}, nil +} + +func confidenceArg(value *float64) sql.NullFloat64 { + if value == nil { + return sql.NullFloat64{} + } + return sql.NullFloat64{Float64: *value, Valid: true} +} + +func relationshipDateArgs(bound *PartialDate) []any { + if bound == nil { + return []any{nil, nil, nil} + } + return PartialDateArgs(*bound) +} + +func nullableVCardText(value string) sql.NullString { + if value == "" { + return sql.NullString{} + } + return sql.NullString{String: value, Valid: true} +} + +func nullableVCardPointer(value *string) sql.NullString { + if value == nil { + return sql.NullString{} + } + return nullableVCardText(*value) +} + +func scanVCardIdentity(property, group, propID, pid, altID sql.NullString) VCardIdentity { + identity := VCardIdentity{Property: property.String} + if group.Valid { + identity.Group = &group.String + } + if propID.Valid { + identity.PropID = &propID.String + } + if pid.Valid && pid.String != "" { + identity.PID = strings.Split(pid.String, ",") + } + if altID.Valid { + identity.AltID = &altID.String + } + return identity +} + +type personRelationshipScan struct { + edge PersonRelationship + startYear sql.NullInt64 + startMonth sql.NullInt64 + startDay sql.NullInt64 + endYear sql.NullInt64 + endMonth sql.NullInt64 + endDay sql.NullInt64 + status string + notes sql.NullString + source string + sourceRef sql.NullString + confidence sql.NullFloat64 + property sql.NullString + group sql.NullString + propID sql.NullString + pid sql.NullString + altID sql.NullString +} + +// destinations is the sole mapping for personRelationshipColumns. Both edge +// and endpoint-view scanners use it, keeping the canonical row hydration in +// lockstep as columns evolve. +func (scan *personRelationshipScan) destinations() []any { + return []any{ + &scan.edge.ID, &scan.edge.SourcePersonID, &scan.edge.TargetPersonID, &scan.edge.RelationshipTypeID, + &scan.edge.TypeSlug, &scan.edge.ForwardLabel, &scan.edge.ReverseLabel, &scan.edge.IsSymmetric, + &scan.startYear, &scan.startMonth, &scan.startDay, &scan.endYear, &scan.endMonth, &scan.endDay, &scan.status, &scan.notes, + &scan.source, &scan.sourceRef, &scan.confidence, &scan.property, &scan.group, &scan.propID, &scan.pid, &scan.altID, + &scan.edge.CreatedBy, &scan.edge.UpdatedBy, &scan.edge.Revision, &scan.edge.CreatedAt, &scan.edge.UpdatedAt, + } +} + +func (scan *personRelationshipScan) relationship() PersonRelationship { + edge := scan.edge + if scan.startYear.Valid { + value := ScanPartialDate(scan.startYear, scan.startMonth, scan.startDay) + edge.StartDate = &value + } + if scan.endYear.Valid { + value := ScanPartialDate(scan.endYear, scan.endMonth, scan.endDay) + edge.EndDate = &value + } + edge.Status = RelationshipStatus(scan.status) + edge.Source = Provenance(scan.source) + if scan.notes.Valid { + edge.Notes = &scan.notes.String + } + if scan.sourceRef.Valid { + edge.SourceRef = &scan.sourceRef.String + } + if scan.confidence.Valid { + edge.Confidence = &scan.confidence.Float64 + } + edge.VCardIdentity = scanVCardIdentity(scan.property, scan.group, scan.propID, scan.pid, scan.altID) + return edge +} + +func scanPersonRelationship(row scanner) (*PersonRelationship, error) { + var scan personRelationshipScan + if err := row.Scan(scan.destinations()...); err != nil { + return nil, err + } + edge := scan.relationship() + return &edge, nil +} + +// PersonRelationshipView is one canonical edge rendered from one endpoint's +// point of view. Direction and CounterpartLabel are computed from the row's +// orientation and its type's labels; there is no second stored row. +type PersonRelationshipView struct { + Relationship PersonRelationship `json:"relationship"` + Direction RelationshipDirection `json:"direction"` + CounterpartPersonID int64 `json:"counterpart_person_id"` + CounterpartLabel string `json:"counterpart_label"` + CounterpartDisplayName *string `json:"counterpart_display_name,omitempty"` + CounterpartVCardUID string `json:"counterpart_vcard_uid"` +} + +// PersonRelationshipListOptions scopes an endpoint view. The default shows +// only currently-true relationships; IncludeEnded adds closed intervals so a +// caller can render history without a second entry point. +type PersonRelationshipListOptions struct { + IncludeEnded bool +} + +// ListPersonRelationshipsContext returns every relationship in which personID +// participates, rendered from that person's side. +// +// The counterpart join is an equality join on persons(id) through the same +// CASE expression used for the counterpart ID, so each edge yields exactly +// one row without DISTINCT. A self-edge would break that guarantee, which is +// why the table forbids one structurally. +// +// Ordering is CASE-based rather than a bare boolean sort so SQLite's 0/1 +// integers and PostgreSQL's booleans produce the same sequence. +func (s *Store) ListPersonRelationshipsContext( + ctx context.Context, personID int64, opts PersonRelationshipListOptions, +) ([]PersonRelationshipView, error) { + currentFilter := "" + if !opts.IncludeEnded { + currentFilter = " AND r.end_year IS NULL" + } + query := ` + SELECT ` + personRelationshipColumns + `, + CASE WHEN r.source_person_id = ? THEN r.target_person_id + ELSE r.source_person_id END AS counterpart_person_id, + CASE WHEN r.source_person_id = ? THEN ? ELSE ? END AS direction, + CASE WHEN r.source_person_id = ? THEN t.reverse_label + ELSE t.forward_label END AS counterpart_label, + cp.display_name AS counterpart_display_name, + cp.vcard_uid AS counterpart_vcard_uid + ` + personRelationshipFrom + ` + JOIN persons cp ON cp.id = CASE WHEN r.source_person_id = ? + THEN r.target_person_id + ELSE r.source_person_id END + WHERE (r.source_person_id = ? OR r.target_person_id = ?)` + currentFilter + ` + ORDER BY CASE WHEN r.end_year IS NULL THEN 0 ELSE 1 END, + LOWER(COALESCE(cp.display_name, cp.vcard_uid)), + t.slug, r.id + ` + rows, err := s.db.QueryContext(ctx, query, + personID, + personID, string(RelationshipDirectionOutgoing), string(RelationshipDirectionIncoming), + personID, + personID, + personID, personID, + ) + if err != nil { + return nil, fmt.Errorf("list relationships for person %d: %w", personID, err) + } + defer func() { _ = rows.Close() }() + + views := make([]PersonRelationshipView, 0) + for rows.Next() { + view, scanErr := scanPersonRelationshipView(rows) + if scanErr != nil { + return nil, fmt.Errorf("scan relationship for person %d: %w", personID, scanErr) + } + views = append(views, *view) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate relationships for person %d: %w", personID, err) + } + return views, nil +} + +func scanPersonRelationshipView(row scanner) (*PersonRelationshipView, error) { + var ( + scan personRelationshipScan + view PersonRelationshipView + direction string + displayName sql.NullString + ) + destinations := scan.destinations() + destinations = append(destinations, + &view.CounterpartPersonID, &direction, &view.CounterpartLabel, + &displayName, &view.CounterpartVCardUID, + ) + if err := row.Scan(destinations...); err != nil { + return nil, err + } + view.Relationship = scan.relationship() + view.Direction = RelationshipDirection(direction) + if displayName.Valid { + view.CounterpartDisplayName = &displayName.String + } + return &view, nil +} diff --git a/internal/store/person_relationships_test.go b/internal/store/person_relationships_test.go new file mode 100644 index 000000000..a83c7bb6d --- /dev/null +++ b/internal/store/person_relationships_test.go @@ -0,0 +1,341 @@ +package store_test + +import ( + "context" + "math" + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil/storetest" +) + +// mustTwoPersons promotes two synthetic participants to durable persons and +// returns their person IDs in creation order. +func mustTwoPersons(t *testing.T, f *storetest.Fixture) (int64, int64) { + t.Helper() + aliceParticipant := f.EnsureParticipant("alice@example.com", "alice", "example.com") + bobParticipant := f.EnsureParticipant("bob@example.com", "bob", "example.com") + alice, _, err := f.Store.CreatePersonFromParticipant(aliceParticipant) + require.NoError(t, err) + bob, _, err := f.Store.CreatePersonFromParticipant(bobParticipant) + require.NoError(t, err) + return alice.ID, bob.ID +} + +func TestAddPersonRelationshipCanonicalizesOrientationAndSymmetry(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + alice, bob := mustTwoPersons(t, f) + + group, propID, altID := "item1", "prop-1", "alt-1" + parent, err := f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: alice, TargetPersonID: bob, TypeSlug: "parent", + Source: store.ProvenanceUser, Actor: "user", + VCardIdentity: store.VCardIdentity{ + Property: "RELATED", Group: &group, PropID: &propID, PID: []string{"1.1", "2.1"}, AltID: &altID, + }, + }) + require.NoError(err) + assert.Equal(alice, parent.SourcePersonID) + assert.Equal(bob, parent.TargetPersonID) + assert.Equal("parent", parent.TypeSlug) + assert.Equal("parent", parent.ForwardLabel) + assert.Equal("child", parent.ReverseLabel) + assert.False(parent.IsSymmetric) + assert.Equal(store.RelationshipStatusActive, parent.Status) + assert.Equal(int64(1), parent.Revision) + assert.Equal("RELATED", parent.VCardIdentity.Property) + require.NotNil(parent.VCardIdentity.Group) + assert.Equal(group, *parent.VCardIdentity.Group) + assert.Equal([]string{"1.1", "2.1"}, parent.VCardIdentity.PID) + + _, err = f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: bob, TargetPersonID: alice, TypeSlug: "child", + Source: store.ProvenanceUser, Actor: "user", + }) + require.ErrorIs(err, store.ErrPersonRelationshipDuplicate) + + lower, higher := alice, bob + if lower > higher { + lower, higher = higher, lower + } + spouse, err := f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: higher, TargetPersonID: lower, TypeSlug: "spouse", + Source: store.ProvenanceUser, Actor: "user", + }) + require.NoError(err) + assert.Equal(lower, spouse.SourcePersonID) + assert.Equal(higher, spouse.TargetPersonID) + assert.True(spouse.IsSymmetric) + _, err = f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: lower, TargetPersonID: higher, TypeSlug: "spouse", + Source: store.ProvenanceUser, Actor: "user", + }) + require.ErrorIs(err, store.ErrPersonRelationshipDuplicate) + + forward, err := f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: alice, TargetPersonID: bob, TypeSlug: "agent", + Source: store.ProvenanceUser, Actor: "user", + }) + require.NoError(err) + reverse, err := f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: bob, TargetPersonID: alice, TypeSlug: "agent", + Source: store.ProvenanceUser, Actor: "user", + }) + require.NoError(err) + assert.NotEqual(forward.ID, reverse.ID) +} + +func TestPersonRelationshipValidatesInputAndConstructedDateBounds(t *testing.T) { + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + alice, bob := mustTwoPersons(t, f) + + _, err := f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: alice, TargetPersonID: alice, TypeSlug: "friend", Source: store.ProvenanceUser, Actor: "user", + }) + require.ErrorIs(err, store.ErrPersonRelationshipSelf) + _, err = f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: alice, TargetPersonID: 999999, TypeSlug: "friend", Source: store.ProvenanceUser, Actor: "user", + }) + require.ErrorIs(err, store.ErrPersonNotFound) + _, err = f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: alice, TargetPersonID: bob, TypeSlug: "missing", Source: store.ProvenanceUser, Actor: "user", + }) + require.ErrorIs(err, store.ErrRelationshipTypeNotFound) + + yearless := store.PartialDate{Month: new(4), Day: new(12)} + _, err = f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: alice, TargetPersonID: bob, TypeSlug: "friend", StartDate: &yearless, + Source: store.ProvenanceUser, Actor: "user", + }) + require.ErrorIs(err, store.ErrInvalidPartialDate) + invalid := store.PartialDate{Year: new(2020), Month: new(13)} + _, err = f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: alice, TargetPersonID: bob, TypeSlug: "friend", StartDate: &invalid, + Source: store.ProvenanceUser, Actor: "user", + }) + require.ErrorIs(err, store.ErrInvalidPartialDate) + + from, err := store.ParseRelationshipDate("2020-06") + require.NoError(err) + until, err := store.ParseRelationshipDate("2019") + require.NoError(err) + _, err = f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: alice, TargetPersonID: bob, TypeSlug: "friend", StartDate: &from, EndDate: &until, + Source: store.ProvenanceUser, Actor: "user", + }) + require.ErrorIs(err, store.ErrPersonRelationshipInterval) + confidence := 0.5 + _, err = f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: alice, TargetPersonID: bob, TypeSlug: "friend", Source: store.ProvenanceUser, + Confidence: &confidence, Actor: "user", + }) + require.ErrorIs(err, store.ErrConfidenceScope) + notANumber := math.NaN() + _, err = f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: alice, TargetPersonID: bob, TypeSlug: "acquaintance", Source: store.ProvenanceEnrichment, + Confidence: ¬ANumber, Actor: "system", + }) + require.ErrorIs(err, store.ErrConfidenceScope, "NaN is not an in-range confidence") +} + +func TestEndUpdateDeletePersonRelationshipUsesCASAndKeepsHistory(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + alice, bob := mustTwoPersons(t, f) + edge, err := f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: alice, TargetPersonID: bob, TypeSlug: "spouse", Source: store.ProvenanceUser, Actor: "user", + }) + require.NoError(err) + + yearless := store.PartialDate{Month: new(4), Day: new(12)} + _, err = f.Store.EndPersonRelationshipContext(ctx, edge.ID, edge.Revision, yearless, "user") + require.ErrorIs(err, store.ErrInvalidPartialDate) + invalid := store.PartialDate{Year: new(2020), Month: new(13)} + _, err = f.Store.EndPersonRelationshipContext(ctx, edge.ID, edge.Revision, invalid, "user") + require.ErrorIs(err, store.ErrInvalidPartialDate) + + until, err := store.ParseRelationshipDate("2021-08") + require.NoError(err) + ended, err := f.Store.EndPersonRelationshipContext(ctx, edge.ID, edge.Revision, until, "user") + require.NoError(err) + assert.Equal(store.RelationshipStatusEnded, ended.Status) + require.NotNil(ended.EndDate) + assert.Equal("2021-08", ended.EndDate.String()) + reestablished, err := f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: alice, TargetPersonID: bob, TypeSlug: "spouse", Source: store.ProvenanceUser, Actor: "user", + }) + require.NoError(err) + assert.NotEqual(edge.ID, reestablished.ID, "ending preserves history and frees the active slot") + _, err = f.Store.EndPersonRelationshipContext(ctx, edge.ID, edge.Revision, until, "user") + require.ErrorIs(err, store.ErrPersonRelationshipRevisionConflict) + + notes := " met at the block party " + updated, err := f.Store.UpdatePersonRelationshipNotesContext(ctx, edge.ID, ended.Revision, ¬es, "user") + require.NoError(err) + require.NotNil(updated.Notes) + assert.Equal("met at the block party", *updated.Notes) + require.ErrorIs(f.Store.DeletePersonRelationshipContext(ctx, edge.ID, ended.Revision), store.ErrPersonRelationshipRevisionConflict) + require.NoError(f.Store.DeletePersonRelationshipContext(ctx, edge.ID, updated.Revision)) + _, err = f.Store.GetPersonRelationshipContext(ctx, edge.ID) + require.ErrorIs(err, store.ErrPersonRelationshipNotFound) +} + +func TestPatchPersonRelationshipIsAtomicAndBumpsRevisionOnce(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + alice, bob := mustTwoPersons(t, f) + edge, err := f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: alice, TargetPersonID: bob, TypeSlug: "spouse", Source: store.ProvenanceUser, Actor: "user", + }) + require.NoError(err) + + until, err := store.ParseRelationshipDate("2021-08") + require.NoError(err) + tooLong := strings.Repeat("x", 4097) + _, err = f.Store.PatchPersonRelationshipContext(ctx, edge.ID, edge.Revision, + store.PersonRelationshipPatch{EndDate: &until, UpdateEndDate: true, Notes: &tooLong, UpdateNotes: true}, "user") + require.ErrorIs(err, store.ErrPersonRelationshipInvalid) + unchanged, err := f.Store.GetPersonRelationshipContext(ctx, edge.ID) + require.NoError(err) + assert.Nil(unchanged.EndDate) + assert.Nil(unchanged.Notes) + assert.Equal(edge.Revision, unchanged.Revision) + + notes := " met at the block party " + updated, err := f.Store.PatchPersonRelationshipContext(ctx, edge.ID, edge.Revision, + store.PersonRelationshipPatch{EndDate: &until, UpdateEndDate: true, Notes: ¬es, UpdateNotes: true}, "user") + require.NoError(err) + require.NotNil(updated.EndDate) + require.NotNil(updated.Notes) + assert.Equal("2021-08", updated.EndDate.String()) + assert.Equal("met at the block party", *updated.Notes) + assert.Equal(edge.Revision+1, updated.Revision) +} + +// TestPersonRelationshipConstraintsAreEnforcedByTheDatabase deliberately +// writes through Store.DB(), bypassing Go validation, so each portable CHECK +// is exercised on both SQLite and PostgreSQL rather than merely asserted at a +// store boundary. +func TestPersonRelationshipConstraintsAreEnforcedByTheDatabase(t *testing.T) { + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + alice, bob := mustTwoPersons(t, f) + insert := f.Store.Rebind(` + INSERT INTO person_relationships ( + source_person_id, target_person_id, relationship_type_id, + start_year, start_month, start_day, end_year, end_month, end_day, + status, source, confidence, created_by, updated_by + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'user', 'user') + `) + tests := []struct { + name string + typeSlug string + source, target int64 + startYear, startMonth, startDay any + endYear, endMonth, endDay any + provenance, confidence any + }{ + {"self edge", "contact", alice, alice, nil, nil, nil, nil, nil, nil, "user", nil}, + {"start month without year", "acquaintance", alice, bob, nil, 4, nil, nil, nil, nil, "user", nil}, + {"start day without month", "friend", alice, bob, 2019, nil, 12, nil, nil, nil, "user", nil}, + {"start month range", "met", alice, bob, 2019, 13, nil, nil, nil, nil, "user", nil}, + {"start year range", "co-worker", alice, bob, 0, nil, nil, nil, nil, nil, "user", nil}, + {"start day range", "colleague", alice, bob, 2019, 4, 32, nil, nil, nil, "user", nil}, + {"end month without year", "co-resident", alice, bob, nil, nil, nil, nil, 6, nil, "user", nil}, + {"end day without month", "neighbor", alice, bob, nil, nil, nil, 2019, nil, 12, "user", nil}, + {"end month range", "sibling", alice, bob, nil, nil, nil, 2019, 13, nil, "user", nil}, + {"end year range", "spouse", alice, bob, nil, nil, nil, 0, nil, nil, "user", nil}, + {"end day range", "kin", alice, bob, nil, nil, nil, 2019, 4, 32, "user", nil}, + {"bad provenance", "date", alice, bob, nil, nil, nil, nil, nil, nil, "guess", nil}, + {"declared confidence", "sweetheart", alice, bob, nil, nil, nil, nil, nil, nil, "user", 0.5}, + {"confidence range", "parent", alice, bob, nil, nil, nil, nil, nil, nil, "enrichment", 1.5}, + } + require.Len(tests, 14) + seen := make(map[string]struct{}, len(tests)) + for _, test := range tests { + seen[test.typeSlug] = struct{}{} + } + require.Len(seen, len(tests), "distinct types prevent a unique-index error from masking a CHECK") + for _, test := range tests { + relationshipType, err := f.Store.GetRelationshipTypeBySlugContext(ctx, test.typeSlug) + require.NoError(err) + _, err = f.Store.DB().Exec(insert, + test.source, test.target, relationshipType.ID, + test.startYear, test.startMonth, test.startDay, test.endYear, test.endMonth, test.endDay, + "active", test.provenance, test.confidence, + ) + require.Errorf(err, "database must reject %s", test.name) + } + accepted, err := f.Store.GetRelationshipTypeBySlugContext(ctx, "muse") + require.NoError(err) + _, err = f.Store.DB().Exec(insert, + alice, bob, accepted.ID, 2019, 4, 12, nil, nil, nil, "active", "user", nil, + ) + require.NoError(err) +} + +func TestPersonRelationshipConstraintsAndColumnParity(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + alice, bob := mustTwoPersons(t, f) + friend, err := f.Store.GetRelationshipTypeBySlugContext(ctx, "friend") + require.NoError(err) + _, err = f.Store.DB().Exec(f.Store.Rebind(` + INSERT INTO person_relationships (source_person_id, target_person_id, relationship_type_id, start_month, status, source, created_by, updated_by) + VALUES (?, ?, ?, 4, 'active', 'user', 'user', 'user')`), alice, bob, friend.ID) + require.Error(err, "database must reject a relationship bound without a year") + _, err = f.Store.DB().Exec(f.Store.Rebind(` + INSERT INTO person_relationships (source_person_id, target_person_id, relationship_type_id, status, source, confidence, created_by, updated_by) + VALUES (?, ?, ?, 'active', 'user', 0.5, 'user', 'user')`), alice, bob, friend.ID) + require.Error(err, "database must reject confidence on a declared source") + + columnsForTable := func(table string) []string { + rows, queryErr := f.Store.DB().Query(`SELECT * FROM ` + table + ` WHERE 1 = 0`) + require.NoError(queryErr) + defer func() { require.NoError(rows.Close()) }() + columns, queryErr := rows.Columns() + require.NoError(queryErr) + require.NoError(rows.Err()) + slices.Sort(columns) + return columns + } + for table, want := range map[string][]string{ + "relationship_types": {"color", "created_at", "description", "forward_label", "icon", "id", "inverse_type_id", "is_canonical", "is_deletable", "is_symmetric", "ownership", "reverse_label", "revision", "slug", "universal_id", "updated_at", "vcard_related_type"}, + "person_relationships": {"confidence", "created_at", "created_by", "end_day", "end_month", "end_year", "id", "notes", "relationship_type_id", "revision", "source", "source_person_id", "source_ref", "start_day", "start_month", "start_year", "status", "target_person_id", "updated_at", "updated_by", "vcard_altid", "vcard_group", "vcard_pid", "vcard_prop_id", "vcard_property"}, + "person_relationship_reviews": {"accepted_relationship_id", "created_at", "created_by", "id", "matched_person_id", "person_id", "raw_related_type", "raw_related_value", "reviewed_at", "reviewed_by", "source", "source_ref", "status", "updated_at", "value_kind", "vcard_altid", "vcard_group", "vcard_pid", "vcard_prop_id", "vcard_property"}, + } { + assert.Equal(want, columnsForTable(table)) + } +} + +func TestDeletingAPersonCascadesItsRelationships(t *testing.T) { + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + alice, bob := mustTwoPersons(t, f) + edge, err := f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: alice, TargetPersonID: bob, TypeSlug: "friend", Source: store.ProvenanceUser, Actor: "user", + }) + require.NoError(err) + person, err := f.Store.GetPerson(bob) + require.NoError(err) + require.NoError(f.Store.DeletePerson(bob, person.Revision)) + _, err = f.Store.GetPersonRelationshipContext(ctx, edge.ID) + require.ErrorIs(err, store.ErrPersonRelationshipNotFound) +} diff --git a/internal/store/relationship_interval.go b/internal/store/relationship_interval.go new file mode 100644 index 000000000..b708d4cf2 --- /dev/null +++ b/internal/store/relationship_interval.go @@ -0,0 +1,75 @@ +package store + +import ( + "errors" + "fmt" + "strings" +) + +// ErrPersonRelationshipInterval reports a world-time interval that cannot be +// true, such as an end that precedes its start. +var ErrPersonRelationshipInterval = errors.New("invalid relationship interval") + +// ParseRelationshipDate parses one bound of a relationship's world-time +// interval. +// +// It narrows the shared PartialDate contract: a relationship bound MUST carry +// a year. The shared parser also accepts RFC 6350's truncated "--04-12" and +// "---12" forms, which are meaningful for a recurring birthday but not for an +// interval bound — a relationship cannot begin on "some April 12th" — and +// which no ordering could place. Rejecting them here keeps every stored bound +// orderable instead of admitting a value the interval check would have to +// ignore. +func ParseRelationshipDate(raw string) (PartialDate, error) { + parsed, err := ParsePartialDate(strings.TrimSpace(raw)) + if err != nil { + return PartialDate{}, err + } + if parsed.Year == nil { + return PartialDate{}, fmt.Errorf( + "%w: a relationship date requires a year, got %q", ErrInvalidPartialDate, raw) + } + return parsed, nil +} + +// validateRelationshipInterval rejects an end that precedes its start. A nil +// bound is open-ended and always valid. +// +// Comparison uses CompareAtSharedPrecision, so only the components both bounds +// specify participate. That matters: "2019-06" to "2019" is not a +// contradiction — it says the relationship began in June 2019 and ended +// sometime that year — whereas a naive string or full-date comparison would +// reject it. +func validateRelationshipInterval(from, until *PartialDate) error { + if err := validateRelationshipBound(from); err != nil { + return err + } + if err := validateRelationshipBound(until); err != nil { + return err + } + if from == nil || until == nil { + return nil + } + if CompareAtSharedPrecision(*from, *until) > 0 { + return fmt.Errorf("%w: start_date %q is after end_date %q", + ErrPersonRelationshipInterval, from.String(), until.String()) + } + return nil +} + +// validateRelationshipBound validates values constructed directly by callers, +// not just values returned by ParseRelationshipDate. Relationship writers take +// PartialDate values, so treating the parser as the only entry point would let +// a yearless or calendar-invalid bound reach SQL. +func validateRelationshipBound(bound *PartialDate) error { + if bound == nil { + return nil + } + if err := bound.Validate(); err != nil { + return fmt.Errorf("%w: relationship date %q", ErrInvalidPartialDate, bound.String()) + } + if bound.Year == nil { + return fmt.Errorf("%w: a relationship date requires a year", ErrInvalidPartialDate) + } + return nil +} diff --git a/internal/store/relationship_interval_test.go b/internal/store/relationship_interval_test.go new file mode 100644 index 000000000..d713ee417 --- /dev/null +++ b/internal/store/relationship_interval_test.go @@ -0,0 +1,101 @@ +package store + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseRelationshipDateRequiresAYear(t *testing.T) { + // Local helpers: make lint-ci runs testify-helper-check, which fails any + // test body with more than three direct testify package calls and at least + // two of one kind. + assert := assert.New(t) + require := require.New(t) + + for _, raw := range []string{"2019", "2019-04", "2019-04-12", " 2019-04 "} { + got, err := ParseRelationshipDate(raw) + require.NoErrorf(err, "value %q must parse", raw) + require.NotNil(got.Year) + } + + // The shared type also accepts the two year-less RFC 6350 truncated forms + // its contract documents ("--04-12" and "---12"), which are meaningful for + // a recurring BDAY but not for a relationship interval: a relationship + // cannot start on "some April 12th" with no year. PR 6 narrows the accepted + // set rather than storing a bound it cannot order. Only the two documented + // forms are asserted here — do not add a form the dependency's contract + // does not promise. + for _, raw := range []string{"--04-12", "---12"} { + shared, err := ParsePartialDate(raw) + require.NoErrorf(err, "the shared type is expected to accept %q", raw) + assert.Nil(shared.Year) + + _, err = ParseRelationshipDate(raw) + require.Errorf(err, "a relationship interval must reject %q", raw) + require.ErrorIs(err, ErrInvalidPartialDate) + } + + for _, raw := range []string{"", " ", "198", "2019-13", "2019-04-31", "abcd"} { + _, err := ParseRelationshipDate(raw) + require.Errorf(err, "value %q must not parse", raw) + require.ErrorIs(err, ErrInvalidPartialDate) + } +} + +func TestValidateRelationshipIntervalUsesSharedPrecision(t *testing.T) { + tests := []struct { + name string + from string + until string + wantErr bool + }{ + {name: "open interval", from: "2019", until: ""}, + {name: "no start", from: "", until: "2019"}, + {name: "same year", from: "2019", until: "2019"}, + {name: "ordered years", from: "2018", until: "2019"}, + {name: "ordered months", from: "2019-03", until: "2019-04"}, + {name: "ordered days", from: "2019-04-01", until: "2019-04-02"}, + { + // Year is the only shared component, so this is not a + // contradiction: started in June 2019, ended sometime in 2019. + name: "coarser end inside the same year", + from: "2019-06", until: "2019", + }, + { + name: "coarser start inside the same year", + from: "2019", until: "2019-06", + }, + {name: "reversed years", from: "2020", until: "2019", wantErr: true}, + {name: "reversed months", from: "2019-06", until: "2019-05", wantErr: true}, + {name: "reversed days", from: "2019-04-12", until: "2019-04-11", wantErr: true}, + {name: "reversed across years", from: "2020-01", until: "2019-12", wantErr: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateRelationshipInterval( + optionalRelationshipDate(t, test.from), + optionalRelationshipDate(t, test.until), + ) + if test.wantErr { + require.Error(t, err) + require.ErrorIs(t, err, ErrPersonRelationshipInterval) + return + } + require.NoError(t, err) + }) + } +} + +// optionalRelationshipDate turns "" into a nil bound so the table can express +// an open-ended interval. +func optionalRelationshipDate(t *testing.T, raw string) *PartialDate { + t.Helper() + if raw == "" { + return nil + } + parsed, err := ParseRelationshipDate(raw) + require.NoError(t, err) + return &parsed +} diff --git a/internal/store/relationship_type_seed.go b/internal/store/relationship_type_seed.go new file mode 100644 index 000000000..1714bd8c5 --- /dev/null +++ b/internal/store/relationship_type_seed.go @@ -0,0 +1,276 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +// systemRelationshipTypeSeed is one seeded system relationship type. +// +// UniversalID is a hardcoded opaque UUID, generated once when this table was +// authored and never recomputed, so a seeded type has the same identity in +// every install. Do not derive it from the slug and do not regenerate an +// existing one: an existing database matches seeds on this column, so changing +// a value would orphan the old row and insert a duplicate alongside it. +type systemRelationshipTypeSeed struct { + Slug string + UniversalID string + ForwardLabel string + ReverseLabel string + IsSymmetric bool + IsCanonical bool + InverseSlug string + RelatedType string + Description string +} + +// systemRelationshipTypes seeds one type per registered RELATED TYPE value +// that describes a person-to-person relationship. Labels follow the +// forward/reverse contract documented on the relationship_types table: +// forward_label completes " is the ___ of ". +// +// No gendered parent variants (father/mother/son/daughter) are seeded. The +// registry has none, and Gramps' gendered parent columns are explicitly +// rejected by the roadmap. +var systemRelationshipTypes = []systemRelationshipTypeSeed{ + {Slug: "contact", UniversalID: "dd74c978-cc03-4fce-875f-c6d03b84feeb", + ForwardLabel: "contact", ReverseLabel: "contact", IsSymmetric: true, IsCanonical: true, RelatedType: "contact"}, + {Slug: "acquaintance", UniversalID: "dcee8148-7f92-4cdf-ac12-e33fb36a5ceb", + ForwardLabel: "acquaintance", ReverseLabel: "acquaintance", IsSymmetric: true, IsCanonical: true, RelatedType: "acquaintance"}, + {Slug: "friend", UniversalID: "a1121fb7-8c6b-40d4-9fe1-1cb62dabbd88", + ForwardLabel: "friend", ReverseLabel: "friend", IsSymmetric: true, IsCanonical: true, RelatedType: "friend"}, + {Slug: "met", UniversalID: "70cda530-be30-4cd5-8ef3-3ac197277d9d", + ForwardLabel: "met", ReverseLabel: "met", IsSymmetric: true, IsCanonical: true, RelatedType: "met", + Description: "The two people have met in person."}, + {Slug: "co-worker", UniversalID: "523f6e4a-b439-4b44-9c0a-ba5d6145c138", + ForwardLabel: "co-worker", ReverseLabel: "co-worker", IsSymmetric: true, IsCanonical: true, RelatedType: "co-worker"}, + {Slug: "colleague", UniversalID: "3d2b5f28-4168-497a-a66f-9ed12fe54223", + ForwardLabel: "colleague", ReverseLabel: "colleague", IsSymmetric: true, IsCanonical: true, RelatedType: "colleague"}, + {Slug: "co-resident", UniversalID: "e9942842-51d2-465b-9fbd-81ed348fdc2e", + ForwardLabel: "co-resident", ReverseLabel: "co-resident", IsSymmetric: true, IsCanonical: true, RelatedType: "co-resident"}, + {Slug: "neighbor", UniversalID: "32a4e047-848f-4302-957a-4f3736ee29e9", + ForwardLabel: "neighbor", ReverseLabel: "neighbor", IsSymmetric: true, IsCanonical: true, RelatedType: "neighbor"}, + {Slug: "sibling", UniversalID: "b94d3c6c-dd83-4861-a42e-3fc1e5e684df", + ForwardLabel: "sibling", ReverseLabel: "sibling", IsSymmetric: true, IsCanonical: true, RelatedType: "sibling"}, + {Slug: "spouse", UniversalID: "9f055281-6064-40ea-86fc-6b1a5d620d0b", + ForwardLabel: "spouse", ReverseLabel: "spouse", IsSymmetric: true, IsCanonical: true, RelatedType: "spouse"}, + {Slug: "kin", UniversalID: "8d29030b-cd28-4966-96d2-3beddbbc579a", + ForwardLabel: "relative", ReverseLabel: "relative", IsSymmetric: true, IsCanonical: true, RelatedType: "kin", + Description: "A family relative whose exact relation is unspecified."}, + {Slug: "date", UniversalID: "1f6c13b3-496a-4817-b416-6d07b8adb764", + ForwardLabel: "date", ReverseLabel: "date", IsSymmetric: true, IsCanonical: true, RelatedType: "date"}, + {Slug: "sweetheart", UniversalID: "80cedce5-5e11-4dee-87ff-39846d97d674", + ForwardLabel: "sweetheart", ReverseLabel: "sweetheart", IsSymmetric: true, IsCanonical: true, RelatedType: "sweetheart"}, + {Slug: "parent", UniversalID: "cb3869d4-683b-48e3-a30a-4e8f7e7dc7fb", + ForwardLabel: "parent", ReverseLabel: "child", IsCanonical: true, InverseSlug: "child", RelatedType: "parent", + Description: "The canonical orientation of the parent/child pair; a 'child' write is stored as 'parent' with the endpoints swapped."}, + {Slug: "child", UniversalID: "676d5466-8ecc-4cb9-a801-042e8ea87341", + ForwardLabel: "child", ReverseLabel: "parent", IsCanonical: false, InverseSlug: "parent", RelatedType: "child", + Description: "The non-canonical orientation of the parent/child pair, kept so RELATED;TYPE=child maps losslessly."}, + {Slug: "muse", UniversalID: "7202c876-95b3-42c1-920a-54586fc445f3", + ForwardLabel: "muse", ReverseLabel: "inspired by", IsCanonical: true, RelatedType: "muse"}, + {Slug: "crush", UniversalID: "8eecd031-7f3c-4740-9290-46a8399e25d2", + ForwardLabel: "crush", ReverseLabel: "admirer", IsCanonical: true, RelatedType: "crush"}, + {Slug: "agent", UniversalID: "6f2ee644-ba8d-414b-9134-a800701c1aca", + ForwardLabel: "agent", ReverseLabel: "principal", IsCanonical: true, RelatedType: "agent"}, + {Slug: "emergency", UniversalID: "3f3916a0-de39-445d-96ee-7a242a4071f2", + ForwardLabel: "emergency contact", ReverseLabel: "emergency contact for", IsCanonical: true, RelatedType: "emergency"}, +} + +// EnsureSeededRelationshipTypes inserts any missing system relationship type, +// repairs structural drift on existing ones, and links the one inverse pair. +// +// It runs on every InitSchema rather than behind an applied_migrations gate. +// That ledger is for one-time data migrations, and a run-once seed could never +// deliver an upstream correction to a database that already exists. It is also +// deliberately not raw INSERT ... ON CONFLICT DO NOTHING in the .sql files, +// because seeded rows would then bypass the validation that user-created rows +// go through. +// +// Reconciliation splits the columns into two classes: +// +// Never written on re-seed (identity + user-owned presentation): +// universal_id, slug, forward_label, reverse_label, vcard_related_type, +// color, icon, description +// Reconciled (structural, so an upstream fix reaches existing databases): +// is_symmetric, is_canonical, inverse_type_id, ownership, is_deletable +// +// forward_label and reverse_label are user-owned because relabelling is the +// point of having a mutable label; a user who renames "friend" to "mate" keeps +// that name across every future upgrade. +// +// vcard_related_type is also user-owned. A user may clear a seeded mapping and +// assign that registered RELATED TYPE to another type. Re-seeding must preserve +// that choice: writing the original mapping back can conflict with the unique +// index after the user has assigned it elsewhere. +// +// is_symmetric is safe to reconcile ONLY because it is immutable through the +// API (RelationshipTypeUpdate does not expose it), so reconciling can never +// undo a user's choice. Note the release-engineering consequence: changing a +// seeded type's symmetry in a future version would re-orient existing edges of +// that type and therefore needs a real data migration, not just a seed edit. +func (s *Store) EnsureSeededRelationshipTypes() error { + return s.EnsureSeededRelationshipTypesContext(context.Background()) +} + +func (s *Store) EnsureSeededRelationshipTypesContext(ctx context.Context) error { + return s.withTxContext(ctx, func(tx *loggedTx) error { + inserted := make(map[string]bool, len(systemRelationshipTypes)) + for _, seed := range systemRelationshipTypes { + didInsert, err := s.reconcileSeededRelationshipTypeTx(ctx, tx, seed) + if err != nil { + return err + } + inserted[seed.UniversalID] = didInsert + } + // Second pass: link inverse pairs now that both rows exist. Keyed on + // universal_id like the first pass, and idempotent because it only + // writes when the stored value differs. + for _, seed := range systemRelationshipTypes { + if seed.InverseSlug == "" { + continue + } + if err := s.reconcileSeededInverseTypeTx(ctx, tx, seed, inserted[seed.UniversalID]); err != nil { + return fmt.Errorf("link relationship type %q inverse: %w", seed.Slug, err) + } + } + return nil + }) +} + +// reconcileSeededRelationshipTypeTx inserts the seed when absent and otherwise +// updates only the structural columns, and only when one actually differs, so a +// no-op run bumps neither revision nor updated_at. +// +// The comparison is done in Go rather than as a SQL `WHERE a <> ? OR b <> ?` +// guard because `<>` against NULL yields NULL, so the nullable inverse_type_id +// column would silently never match and drift on that column would never be +// repaired. +func (s *Store) reconcileSeededRelationshipTypeTx( + ctx context.Context, tx *loggedTx, seed systemRelationshipTypeSeed, +) (bool, error) { + current, err := scanRelationshipType(tx.QueryRowContext(ctx, + `SELECT `+relationshipTypeColumns+` FROM relationship_types WHERE universal_id = ?`, + seed.UniversalID)) + if errors.Is(err, sql.ErrNoRows) { + var description any + if seed.Description != "" { + description = seed.Description + } + var inverseTypeID any + if !seed.IsCanonical { + if err := tx.QueryRowContext(ctx, + `SELECT id FROM relationship_types WHERE slug = ?`, seed.InverseSlug, + ).Scan(&inverseTypeID); err != nil { + return false, fmt.Errorf("load inverse relationship type %q for %q: %w", + seed.InverseSlug, seed.Slug, err) + } + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO relationship_types ( + universal_id, slug, forward_label, reverse_label, + is_symmetric, is_canonical, inverse_type_id, vcard_related_type, description, + ownership, is_deletable + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, FALSE) + `, + seed.UniversalID, seed.Slug, seed.ForwardLabel, seed.ReverseLabel, + seed.IsSymmetric, seed.IsCanonical, inverseTypeID, seed.RelatedType, description, + string(RelationshipTypeOwnershipSystem), + ); err != nil { + return false, fmt.Errorf("seed relationship type %q: %w", seed.Slug, err) + } + return true, nil + } + if err != nil { + return false, fmt.Errorf("load seeded relationship type %q: %w", seed.Slug, err) + } + if !seededRelationshipTypeDiffers(current, seed) { + return false, nil + } + if !seed.IsCanonical { + var inverseTypeID int64 + if err := tx.QueryRowContext(ctx, + `SELECT id FROM relationship_types WHERE slug = ?`, seed.InverseSlug, + ).Scan(&inverseTypeID); err != nil { + return false, fmt.Errorf("load inverse relationship type %q for %q: %w", + seed.InverseSlug, seed.Slug, err) + } + if _, err := tx.ExecContext(ctx, fmt.Sprintf(` + UPDATE relationship_types + SET is_symmetric = ?, is_canonical = ?, inverse_type_id = ?, + ownership = ?, is_deletable = FALSE, + revision = revision + 1, updated_at = %s + WHERE universal_id = ? + `, s.dialect.Now()), + seed.IsSymmetric, seed.IsCanonical, inverseTypeID, + string(RelationshipTypeOwnershipSystem), seed.UniversalID, + ); err != nil { + return false, fmt.Errorf("repair seeded relationship type %q: %w", seed.Slug, err) + } + return false, nil + } + if _, err := tx.ExecContext(ctx, fmt.Sprintf(` + UPDATE relationship_types + SET is_symmetric = ?, is_canonical = ?, + ownership = ?, is_deletable = FALSE, + revision = revision + 1, updated_at = %s + WHERE universal_id = ? + `, s.dialect.Now()), + seed.IsSymmetric, seed.IsCanonical, + string(RelationshipTypeOwnershipSystem), seed.UniversalID, + ); err != nil { + return false, fmt.Errorf("repair seeded relationship type %q: %w", seed.Slug, err) + } + return false, nil +} + +// reconcileSeededInverseTypeTx repairs an inverse link and records a real +// mutation. New seed rows keep their initial revision: their inverse link is +// part of the initial seed construction, not a later reconciliation update. +func (s *Store) reconcileSeededInverseTypeTx( + ctx context.Context, tx *loggedTx, seed systemRelationshipTypeSeed, inserted bool, +) error { + if inserted { + _, err := tx.ExecContext(ctx, ` + UPDATE relationship_types + SET inverse_type_id = ( + SELECT inverse.id FROM relationship_types inverse WHERE inverse.slug = ? + ) + WHERE universal_id = ? + AND (inverse_type_id IS NULL + OR inverse_type_id <> ( + SELECT inverse.id FROM relationship_types inverse WHERE inverse.slug = ? + )) + `, seed.InverseSlug, seed.UniversalID, seed.InverseSlug) + return err + } + _, err := tx.ExecContext(ctx, fmt.Sprintf(` + UPDATE relationship_types + SET inverse_type_id = ( + SELECT inverse.id FROM relationship_types inverse WHERE inverse.slug = ? + ), revision = revision + 1, updated_at = %s + WHERE universal_id = ? + AND (inverse_type_id IS NULL + OR inverse_type_id <> ( + SELECT inverse.id FROM relationship_types inverse WHERE inverse.slug = ? + )) + `, s.dialect.Now()), seed.InverseSlug, seed.UniversalID, seed.InverseSlug) + return err +} + +// seededRelationshipTypeDiffers reports whether any reconciled column has +// drifted from the seed. It deliberately ignores slug, labels, vCard mapping, +// colour, icon, and description, which the seed never rewrites. +func seededRelationshipTypeDiffers( + current *RelationshipType, seed systemRelationshipTypeSeed, +) bool { + if current.IsSymmetric != seed.IsSymmetric || + current.IsCanonical != seed.IsCanonical || + current.Ownership != RelationshipTypeOwnershipSystem || + current.IsDeletable { + return true + } + return false +} diff --git a/internal/store/relationship_types.go b/internal/store/relationship_types.go new file mode 100644 index 000000000..38f6a9cea --- /dev/null +++ b/internal/store/relationship_types.go @@ -0,0 +1,569 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "maps" + "slices" + "strings" + "time" +) + +var ( + ErrRelationshipTypeNotFound = errors.New("relationship type not found") +) + +// relatedTypeValues is the set of TYPE parameter values that name a +// person-to-person RELATION, in registry order. +// +// Source: RFC 6350 Section 6.6.6's type-param-related ABNF production, which +// matches the IANA vCard Elements registry parameter-values table one-to-one +// (registry last updated 2026-01-13). RFC 9554 and RFC 9555 add nothing to +// RELATED/TYPE. RFC 6350's value ABNF is "RELATED-value = URI / text" and +// defines no default TYPE. +// +// Deliberately NOT included: "work" and "home". Both are legal TYPE values on +// RELATED, reaching it through the shared multi-property registry row of +// RFC 6350 Section 5.6, so the registry's accept-set for TYPE on RELATED is 22 +// values while type-param-related admits only these 20. They are excluded +// because they qualify the CONTEXT of a relation rather than naming one: +// neither can supply a meaningful forward/reverse label pair, so neither can +// name a relationship type. An imported RELATED;TYPE=work therefore matches no +// type and is staged for review, which is the correct conservative outcome. +// +// This literal is deliberately self-contained: roadmap PR 6 does not depend +// on PR 1, so it does not import a vendored registry snapshot (nothing in the +// repository vendors these CSVs yet). PR 8 depends on both and owns the test +// that cross-checks this list against that snapshot once it exists. +var relatedTypeValues = []string{ + "contact", "acquaintance", "friend", "met", "co-worker", "colleague", + "co-resident", "neighbor", "child", "parent", "sibling", "spouse", "kin", + "muse", "crush", "date", "sweetheart", "me", "agent", "emergency", +} + +// RelatedTypeValues returns a copy of the registered RELATED TYPE values. +func RelatedTypeValues() []string { + return append([]string(nil), relatedTypeValues...) +} + +// unmappedRelatedTypes records registered RELATED TYPE values that Msgvault +// deliberately does not seed as a person relationship type, each with the +// reason. Keeping the exclusion explicit means every registered value is +// accounted for, so a silent gap cannot hide behind a shorter seed list. +var unmappedRelatedTypes = map[string]string{ + "me": "asserts that the related entity is the vCard owner, which is an " + + "identity claim rather than a relationship; Msgvault models identity " + + "through participant bindings and person merge, so an imported " + + "RELATED;TYPE=me is staged for review instead of becoming an edge", +} + +// UnmappedRelatedTypes returns a copy of the deliberately unseeded RELATED +// TYPE values mapped to the reason each is excluded. +func UnmappedRelatedTypes() map[string]string { + excluded := make(map[string]string, len(unmappedRelatedTypes)) + maps.Copy(excluded, unmappedRelatedTypes) + return excluded +} + +// RelationshipTypeOwnership separates who owns a relationship type from the +// four mutability concerns. It is TEXT rather than a boolean so a later vendor +// or plugin ownership kind does not require a SQLite table rebuild. +type RelationshipTypeOwnership string + +const ( + RelationshipTypeOwnershipSystem RelationshipTypeOwnership = "system" + RelationshipTypeOwnershipUser RelationshipTypeOwnership = "user" +) + +func (o RelationshipTypeOwnership) Valid() bool { + return o == RelationshipTypeOwnershipSystem || o == RelationshipTypeOwnershipUser +} + +// RelationshipType is presentation and interchange metadata for one kind of +// person relationship. See the relationship_types table comment for the +// forward/reverse label contract and the orientation rules. +type RelationshipType struct { + ID int64 `json:"id"` + UniversalID string `json:"universal_id"` + Slug string `json:"slug"` + ForwardLabel string `json:"forward_label"` + ReverseLabel string `json:"reverse_label"` + IsSymmetric bool `json:"is_symmetric"` + IsCanonical bool `json:"is_canonical"` + InverseTypeID *int64 `json:"inverse_type_id,omitempty"` + VCardRelatedType *string `json:"vcard_related_type,omitempty"` + Color *string `json:"color,omitempty"` + Icon *string `json:"icon,omitempty"` + Description *string `json:"description,omitempty"` + Ownership RelationshipTypeOwnership `json:"ownership"` + IsDeletable bool `json:"is_deletable"` + Revision int64 `json:"revision"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// RelationshipDirection names which end of a canonical edge is doing the +// viewing. It is a presentation concept: both values describe the same row. +type RelationshipDirection string + +const ( + // RelationshipDirectionOutgoing means the viewing person is the edge's + // source, so the counterpart is labelled with the type's reverse label. + RelationshipDirectionOutgoing RelationshipDirection = "outgoing" + // RelationshipDirectionIncoming means the viewing person is the edge's + // target, so the counterpart is labelled with the type's forward label. + RelationshipDirectionIncoming RelationshipDirection = "incoming" +) + +// CounterpartLabel returns the label for the person at the other end of an +// edge of this type, given which endpoint is doing the viewing. +// +// Viewing from the source endpoint yields ReverseLabel, because the row +// asserts "source is the ForwardLabel of target" and therefore implies +// "target is the ReverseLabel of source". +func (t RelationshipType) CounterpartLabel(direction RelationshipDirection) string { + if direction == RelationshipDirectionOutgoing { + return t.ReverseLabel + } + return t.ForwardLabel +} + +const relationshipTypeColumns = ` + id, universal_id, slug, forward_label, reverse_label, + is_symmetric, is_canonical, inverse_type_id, vcard_related_type, + color, icon, description, ownership, is_deletable, + revision, created_at, updated_at +` + +func (s *Store) ListRelationshipTypesContext(ctx context.Context) ([]RelationshipType, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT `+relationshipTypeColumns+` FROM relationship_types ORDER BY LOWER(slug), id`) + if err != nil { + return nil, fmt.Errorf("list relationship types: %w", err) + } + defer func() { _ = rows.Close() }() + + types := make([]RelationshipType, 0, len(systemRelationshipTypes)) + for rows.Next() { + relationshipType, scanErr := scanRelationshipType(rows) + if scanErr != nil { + return nil, fmt.Errorf("scan relationship type: %w", scanErr) + } + types = append(types, *relationshipType) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate relationship types: %w", err) + } + return types, nil +} + +func (s *Store) GetRelationshipTypeContext(ctx context.Context, id int64) (*RelationshipType, error) { + relationshipType, err := scanRelationshipType(s.db.QueryRowContext(ctx, + `SELECT `+relationshipTypeColumns+` FROM relationship_types WHERE id = ?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrRelationshipTypeNotFound + } + if err != nil { + return nil, fmt.Errorf("get relationship type %d: %w", id, err) + } + return relationshipType, nil +} + +// GetRelationshipTypeBySlugContext looks a type up by its immutable machine +// slug, which is the stable wire key used by the API and CLI. +func (s *Store) GetRelationshipTypeBySlugContext(ctx context.Context, slug string) (*RelationshipType, error) { + relationshipType, err := scanRelationshipType(s.db.QueryRowContext(ctx, + `SELECT `+relationshipTypeColumns+` FROM relationship_types WHERE slug = ?`, slug)) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("%w: %q", ErrRelationshipTypeNotFound, slug) + } + if err != nil { + return nil, fmt.Errorf("get relationship type %q: %w", slug, err) + } + return relationshipType, nil +} + +func scanRelationshipType(row scanner) (*RelationshipType, error) { + var ( + relationshipType RelationshipType + inverseTypeID sql.NullInt64 + relatedType sql.NullString + color sql.NullString + icon sql.NullString + description sql.NullString + ownership string + ) + if err := row.Scan( + &relationshipType.ID, &relationshipType.UniversalID, &relationshipType.Slug, + &relationshipType.ForwardLabel, &relationshipType.ReverseLabel, + &relationshipType.IsSymmetric, &relationshipType.IsCanonical, + &inverseTypeID, &relatedType, &color, &icon, &description, + &ownership, &relationshipType.IsDeletable, + &relationshipType.Revision, &relationshipType.CreatedAt, &relationshipType.UpdatedAt, + ); err != nil { + return nil, err + } + relationshipType.Ownership = RelationshipTypeOwnership(ownership) + if inverseTypeID.Valid { + relationshipType.InverseTypeID = &inverseTypeID.Int64 + } + if relatedType.Valid { + relationshipType.VCardRelatedType = &relatedType.String + } + if color.Valid { + relationshipType.Color = &color.String + } + if icon.Valid { + relationshipType.Icon = &icon.String + } + if description.Valid { + relationshipType.Description = &description.String + } + return &relationshipType, nil +} + +var ( + ErrRelationshipTypeInvalid = errors.New("invalid relationship type") + ErrRelationshipTypeSlugConflict = errors.New("relationship type slug already exists") + ErrRelationshipTypeRelatedTypeConflict = errors.New("vCard RELATED type is already mapped") + ErrRelationshipTypeRevisionConflict = errors.New("relationship type revision conflict") + ErrRelationshipTypeNotDeletable = errors.New("relationship type is not deletable") + ErrRelationshipTypeInUse = errors.New("relationship type is referenced by person relationships") + ErrRelationshipTypeSymmetricLabels = errors.New("a symmetric relationship type requires identical forward and reverse labels") +) + +// maxRelationshipTypeSlugBytes bounds the immutable machine slug. It is well +// under PostgreSQL's btree key limit and long enough for any real label. +const maxRelationshipTypeSlugBytes = 64 + +// maxRelationshipTypeLabelBytes bounds mutable presentation strings so a +// hostile import cannot store an unbounded label. +const maxRelationshipTypeLabelBytes = 128 + +// RelationshipTypeInput creates a user-owned relationship type. User types +// are always their own canonical orientation: defining a second type as the +// inverse of an existing one is deliberately not supported, because the only +// registry-mandated inverse pair (parent/child) is seeded. +type RelationshipTypeInput struct { + Slug string + ForwardLabel string + ReverseLabel string + IsSymmetric bool + VCardRelatedType *string + Color *string + Icon *string + Description *string +} + +// RelationshipTypeUpdate edits mutable presentation and interchange metadata. +// A nil field is left unchanged. A pointer to the empty string clears a +// nullable field (colour, icon, description, vCard RELATED mapping); the two +// labels are NOT NULL and reject an empty value. +// +// Slug, universal ID, symmetry, canonical orientation, inverse type, and +// system/deletable ownership are immutable: stored edges depend on them, so +// changing one would silently reinterpret existing data. +type RelationshipTypeUpdate struct { + ForwardLabel *string + ReverseLabel *string + VCardRelatedType *string + Color *string + Icon *string + Description *string +} + +func (s *Store) CreateRelationshipTypeContext( + ctx context.Context, input RelationshipTypeInput, +) (*RelationshipType, error) { + slug := strings.TrimSpace(input.Slug) + if err := validateRelationshipTypeSlug(slug); err != nil { + return nil, err + } + forward, err := validateRelationshipLabel("forward_label", input.ForwardLabel) + if err != nil { + return nil, err + } + reverse, err := validateRelationshipLabel("reverse_label", input.ReverseLabel) + if err != nil { + return nil, err + } + if input.IsSymmetric && forward != reverse { + return nil, fmt.Errorf("%w: %q != %q", ErrRelationshipTypeSymmetricLabels, forward, reverse) + } + relatedType, err := validateVCardRelatedType(input.VCardRelatedType) + if err != nil { + return nil, err + } + // A user-created type gets an opaque random UUID, minted server-side and + // never accepted from a client. This reuses the existing unexported + // generator rather than adding a second one, so every externally visible + // Msgvault identity shares one format. (Roadmap PR 2 wraps the same + // function as store.NewAttributeUniversalID for its own definitions.) + universalID, err := newVCardUID() + if err != nil { + return nil, err + } + created, err := scanRelationshipType(s.db.QueryRowContext(ctx, ` + INSERT INTO relationship_types ( + universal_id, slug, forward_label, reverse_label, + is_symmetric, is_canonical, vcard_related_type, + color, icon, description, ownership, is_deletable + ) VALUES (?, ?, ?, ?, ?, TRUE, ?, ?, ?, ?, ?, TRUE) + RETURNING `+relationshipTypeColumns, + universalID, slug, forward, reverse, input.IsSymmetric, relatedType, + normalizeNullableText(input.Color), normalizeNullableText(input.Icon), + normalizeNullableText(input.Description), + string(RelationshipTypeOwnershipUser), + )) + if err != nil { + if s.dialect.IsConflictError(err) { + return nil, s.relationshipTypeConflict(ctx, slug, relatedType) + } + return nil, fmt.Errorf("create relationship type %q: %w", slug, err) + } + return created, nil +} + +// relationshipTypeConflict turns a unique violation into the specific +// sentinel the caller needs, so an API layer can pick the right message +// without parsing driver text. +func (s *Store) relationshipTypeConflict(ctx context.Context, slug string, relatedType any) error { + if relatedType != nil { + var taken int + if err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM relationship_types WHERE vcard_related_type = ?`, relatedType, + ).Scan(&taken); err == nil && taken > 0 { + return fmt.Errorf("%w: %v", ErrRelationshipTypeRelatedTypeConflict, relatedType) + } + } + return fmt.Errorf("%w: %q", ErrRelationshipTypeSlugConflict, slug) +} + +func (s *Store) UpdateRelationshipTypeContext( + ctx context.Context, id, expectedRevision int64, update RelationshipTypeUpdate, +) (*RelationshipType, error) { + var updated *RelationshipType + err := s.withTxContext(ctx, func(tx *loggedTx) error { + current, err := scanRelationshipType(tx.QueryRowContext(ctx, + `SELECT `+relationshipTypeColumns+` FROM relationship_types WHERE id = ?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return ErrRelationshipTypeNotFound + } + if err != nil { + return fmt.Errorf("load relationship type %d: %w", id, err) + } + + forward := current.ForwardLabel + if update.ForwardLabel != nil { + if forward, err = validateRelationshipLabel("forward_label", *update.ForwardLabel); err != nil { + return err + } + } + reverse := current.ReverseLabel + if update.ReverseLabel != nil { + if reverse, err = validateRelationshipLabel("reverse_label", *update.ReverseLabel); err != nil { + return err + } + } + if current.IsSymmetric && forward != reverse { + return fmt.Errorf("%w: %q != %q", ErrRelationshipTypeSymmetricLabels, forward, reverse) + } + + relatedType := nullableTextFromPointer(current.VCardRelatedType) + if update.VCardRelatedType != nil { + if relatedType, err = validateVCardRelatedType(update.VCardRelatedType); err != nil { + return err + } + } + + // Resolve every nullable presentation column in Go. A SQL + // COALESCE(?, column) idiom cannot express "set this to NULL", so + // "leave unchanged" and "clear" are decided here instead. + color := nullableTextFromPointer(current.Color) + if update.Color != nil { + color = normalizeNullableText(update.Color) + } + icon := nullableTextFromPointer(current.Icon) + if update.Icon != nil { + icon = normalizeNullableText(update.Icon) + } + description := nullableTextFromPointer(current.Description) + if update.Description != nil { + description = normalizeNullableText(update.Description) + } + + var updatedID int64 + err = tx.QueryRowContext(ctx, fmt.Sprintf(` + UPDATE relationship_types + SET forward_label = ?, reverse_label = ?, vcard_related_type = ?, + color = ?, icon = ?, description = ?, + revision = revision + 1, updated_at = %s + WHERE id = ? AND revision = ? + RETURNING id + `, s.dialect.Now()), + forward, reverse, relatedType, color, icon, description, + id, expectedRevision).Scan(&updatedID) + if errors.Is(err, sql.ErrNoRows) { + return ErrRelationshipTypeRevisionConflict + } + if err != nil { + if s.dialect.IsConflictError(err) { + return fmt.Errorf("%w: %v", ErrRelationshipTypeRelatedTypeConflict, relatedType) + } + return fmt.Errorf("update relationship type %d: %w", id, err) + } + updated, err = scanRelationshipType(tx.QueryRowContext(ctx, + `SELECT `+relationshipTypeColumns+` FROM relationship_types WHERE id = ?`, updatedID)) + if err != nil { + return fmt.Errorf("reload relationship type %d: %w", id, err) + } + return nil + }) + if err != nil { + return nil, err + } + return updated, nil +} + +// DeleteRelationshipTypeContext removes a user-owned, unreferenced type under +// revision compare-and-swap. Seeded system types are never deletable, and a +// type still referenced by any edge (active or historical) is refused so +// relationship history cannot be silently destroyed through its metadata. +func (s *Store) DeleteRelationshipTypeContext(ctx context.Context, id, expectedRevision int64) error { + return s.withTxContext(ctx, func(tx *loggedTx) error { + var ( + revision int64 + isDeletable bool + ownership string + ) + err := tx.QueryRowContext(ctx, + `SELECT revision, is_deletable, ownership FROM relationship_types WHERE id = ?`, id, + ).Scan(&revision, &isDeletable, &ownership) + if errors.Is(err, sql.ErrNoRows) { + return ErrRelationshipTypeNotFound + } + if err != nil { + return fmt.Errorf("load relationship type %d: %w", id, err) + } + // Both guards, not just is_deletable: a system row must stay + // undeletable even if a future migration or manual edit flipped its + // flag, matching how PR 2 guards seeded attribute definitions. + if !isDeletable || RelationshipTypeOwnership(ownership) == RelationshipTypeOwnershipSystem { + return fmt.Errorf("%w: id %d", ErrRelationshipTypeNotDeletable, id) + } + if revision != expectedRevision { + return ErrRelationshipTypeRevisionConflict + } + var inUse int + if err := tx.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM person_relationships WHERE relationship_type_id = ? + `, id).Scan(&inUse); err != nil { + return fmt.Errorf("check relationship type %d usage: %w", id, err) + } + if inUse > 0 { + return fmt.Errorf("%w: id %d is used by %d relationships", ErrRelationshipTypeInUse, id, inUse) + } + result, err := tx.ExecContext(ctx, + `DELETE FROM relationship_types WHERE id = ? AND revision = ?`, id, expectedRevision, + ) + if err != nil { + return fmt.Errorf("delete relationship type %d: %w", id, err) + } + return checkRelationshipTypeDeleteCASResult(result, id) + }) +} + +// checkRelationshipTypeDeleteCASResult converts a zero-row DELETE into the +// revision conflict required by the delete compare-and-swap contract. A +// concurrent update can change the revision after the initial read, leaving a +// successful statement that matched no rows. +func checkRelationshipTypeDeleteCASResult(result sql.Result, id int64) error { + affected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("count deleted relationship type %d: %w", id, err) + } + if affected == 0 { + return ErrRelationshipTypeRevisionConflict + } + return nil +} + +// validateRelationshipTypeSlug enforces a lowercase, hyphen-separated machine +// name. The slug is immutable and appears in URLs and CLI arguments, so it +// must be stable, unambiguous, and free of case or separator variants. +func validateRelationshipTypeSlug(slug string) error { + if slug == "" { + return fmt.Errorf("%w: slug is required", ErrRelationshipTypeInvalid) + } + if len(slug) > maxRelationshipTypeSlugBytes { + return fmt.Errorf("%w: slug exceeds %d bytes", ErrRelationshipTypeInvalid, maxRelationshipTypeSlugBytes) + } + if strings.HasPrefix(slug, "-") || strings.HasSuffix(slug, "-") || strings.Contains(slug, "--") { + return fmt.Errorf("%w: slug %q must not start, end, or double up on '-'", ErrRelationshipTypeInvalid, slug) + } + for _, r := range slug { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { + return fmt.Errorf("%w: slug %q must match [a-z0-9-]", ErrRelationshipTypeInvalid, slug) + } + } + return nil +} + +func validateRelationshipLabel(field, value string) (string, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "", fmt.Errorf("%w: %s is required", ErrRelationshipTypeInvalid, field) + } + if len(trimmed) > maxRelationshipTypeLabelBytes { + return "", fmt.Errorf("%w: %s exceeds %d bytes", ErrRelationshipTypeInvalid, field, maxRelationshipTypeLabelBytes) + } + return trimmed, nil +} + +// validateVCardRelatedType accepts nil (no mapping), a pointer to the empty +// string (clear the mapping), or one of the registered RELATED TYPE values. +// An unregistered value is refused so the column cannot claim an interchange +// mapping that no vCard client understands. +// +// It returns sql.NullString rather than `any`: the `nilnil` linter +// (.golangci.yml:68) forbids returning (nil, nil) from a function whose first +// result is a pointer, interface, map, channel, or func, and a concrete +// null-able struct expresses "absent" without tripping it. +func validateVCardRelatedType(value *string) (sql.NullString, error) { + if value == nil { + return sql.NullString{}, nil + } + trimmed := strings.ToLower(strings.TrimSpace(*value)) + if trimmed == "" { + return sql.NullString{}, nil + } + if slices.Contains(relatedTypeValues, trimmed) { + return sql.NullString{String: trimmed, Valid: true}, nil + } + return sql.NullString{}, fmt.Errorf("%w: %q is not a registered vCard RELATED TYPE value", + ErrRelationshipTypeInvalid, *value) +} + +// normalizeNullableText turns an absent or blank optional string into a SQL +// NULL so "" and NULL cannot both mean "unset". +func normalizeNullableText(value *string) any { + if value == nil { + return nil + } + trimmed := strings.TrimSpace(*value) + if trimmed == "" { + return nil + } + return trimmed +} + +// nullableTextFromPointer preserves an already-stored optional value when an +// update leaves that column alone. +func nullableTextFromPointer(value *string) any { + if value == nil { + return nil + } + return *value +} diff --git a/internal/store/relationship_types_internal_test.go b/internal/store/relationship_types_internal_test.go new file mode 100644 index 000000000..9ee569d23 --- /dev/null +++ b/internal/store/relationship_types_internal_test.go @@ -0,0 +1,35 @@ +package store + +import ( + "context" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCheckRelationshipTypeDeleteCASResultRejectsZeroAffectedRows(t *testing.T) { + require := require.New(t) + st, err := OpenForTest(filepath.Join(t.TempDir(), "relationship-types.db")) + require.NoError(err) + t.Cleanup(func() { _ = st.Close() }) + require.NoError(st.InitSchema()) + + created, err := st.CreateRelationshipTypeContext(context.Background(), RelationshipTypeInput{ + Slug: "mentor", ForwardLabel: "mentor", ReverseLabel: "mentee", + }) + require.NoError(err) + + // This is the real driver result of DELETE ... WHERE id = ? AND revision = + // ? after a concurrent revision change: the statement succeeds but matches + // no rows. It needs no Task 4 relationship-edge schema. + _, err = st.DB().ExecContext(context.Background(), + `UPDATE relationship_types SET revision = revision + 1 WHERE id = ?`, created.ID) + require.NoError(err) + + result, err := st.DB().ExecContext(context.Background(), + `DELETE FROM relationship_types WHERE id = ? AND revision = ?`, created.ID, created.Revision) + require.NoError(err) + + require.ErrorIs(checkRelationshipTypeDeleteCASResult(result, created.ID), ErrRelationshipTypeRevisionConflict) +} diff --git a/internal/store/relationship_types_test.go b/internal/store/relationship_types_test.go new file mode 100644 index 000000000..bd62f2d2a --- /dev/null +++ b/internal/store/relationship_types_test.go @@ -0,0 +1,533 @@ +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 TestSeededRelationshipTypesCoverEveryRegisteredRelatedValue(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + + types, err := f.Store.ListRelationshipTypesContext(context.Background()) + require.NoError(err) + require.Len(types, 19) + + mapped := make(map[string]store.RelationshipType, len(types)) + for _, relationshipType := range types { + assert.Equal(store.RelationshipTypeOwnershipSystem, relationshipType.Ownership, + "seeded types are system-owned") + assert.False(relationshipType.IsDeletable, "seeded types are not deletable") + assert.Equal(int64(1), relationshipType.Revision) + // Universal IDs are opaque hardcoded UUIDs, never derived from the + // slug, so identity and machine name stay independent. + assert.Regexp(`^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$`, + relationshipType.UniversalID) + assert.NotContains(relationshipType.UniversalID, relationshipType.Slug, + "a seeded universal ID must not embed its slug") + require.NotNil(relationshipType.VCardRelatedType) + mapped[*relationshipType.VCardRelatedType] = relationshipType + } + + // Universal IDs are stable identity: they must be unique and must match + // the hardcoded seed table exactly, because an existing database is + // reconciled by universal_id. Changing one would orphan its row. + seen := make(map[string]string, len(types)) + for _, relationshipType := range types { + previous, duplicate := seen[relationshipType.UniversalID] + assert.Falsef(duplicate, "universal ID %q is shared by %q and %q", + relationshipType.UniversalID, previous, relationshipType.Slug) + seen[relationshipType.UniversalID] = relationshipType.Slug + } + assert.Len(seen, 19) + + // Every registered RELATED TYPE value is accounted for: either it is + // seeded, or it is in the explicit unmapped set with a reason. + unmapped := store.UnmappedRelatedTypes() + assert.Len(unmapped, 1) + assert.Contains(unmapped, "me") + assert.NotEmpty(unmapped["me"], "an unmapped value must document why") + + registered := store.RelatedTypeValues() + assert.Len(registered, 20) + for _, value := range registered { + _, seeded := mapped[value] + _, skipped := unmapped[value] + assert.NotEqualf(seeded, skipped, + "RELATED TYPE %q must be exactly one of seeded or explicitly unmapped", value) + } + assert.Len(mapped, 19) +} + +func TestSeededRelationshipTypeOrientationAndSymmetry(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + + parent, err := f.Store.GetRelationshipTypeBySlugContext(ctx, "parent") + require.NoError(err) + assert.Equal("parent", parent.ForwardLabel) + assert.Equal("child", parent.ReverseLabel) + assert.False(parent.IsSymmetric) + assert.True(parent.IsCanonical) + + child, err := f.Store.GetRelationshipTypeBySlugContext(ctx, "child") + require.NoError(err) + assert.Equal("child", child.ForwardLabel) + assert.Equal("parent", child.ReverseLabel) + assert.False(child.IsSymmetric) + assert.False(child.IsCanonical, "child stores as its canonical inverse, parent") + require.NotNil(child.InverseTypeID) + assert.Equal(parent.ID, *child.InverseTypeID) + require.NotNil(parent.InverseTypeID) + assert.Equal(child.ID, *parent.InverseTypeID) + + // No gendered parent variants are seeded, and neither are the two + // context-qualifying TYPE values ("work", "home") that RFC 6350's shared + // multi-property parameter row also allows on RELATED. + for _, slug := range []string{"father", "mother", "son", "daughter", "work", "home"} { + _, err := f.Store.GetRelationshipTypeBySlugContext(ctx, slug) + require.ErrorIsf(err, store.ErrRelationshipTypeNotFound, "slug %q must not be seeded", slug) + } + for _, contextual := range []string{"work", "home"} { + assert.NotContainsf(store.RelatedTypeValues(), contextual, + "%q qualifies a relation's context and is outside the relationship value set", contextual) + } + + spouse, err := f.Store.GetRelationshipTypeBySlugContext(ctx, "spouse") + require.NoError(err) + assert.True(spouse.IsSymmetric) + assert.Equal(spouse.ForwardLabel, spouse.ReverseLabel) + assert.Nil(spouse.InverseTypeID, "a symmetric type is its own inverse") +} + +func TestEnsureSeededRelationshipTypesIsIdempotentAndPreservesLabelEdits(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + + friend, err := f.Store.GetRelationshipTypeBySlugContext(ctx, "friend") + require.NoError(err) + + renamed := "mate" + updated, err := f.Store.UpdateRelationshipTypeContext(ctx, friend.ID, friend.Revision, + store.RelationshipTypeUpdate{ForwardLabel: &renamed, ReverseLabel: &renamed}) + require.NoError(err) + assert.Equal("mate", updated.ForwardLabel) + + // Corrupt a structural column the way a stale database or a bad manual + // edit would, so the reconciler has something real to repair. + _, err = f.Store.DB().Exec(f.Store.Rebind( + `UPDATE relationship_types SET is_symmetric = FALSE, is_deletable = TRUE WHERE slug = ?`), "friend") + require.NoError(err) + + require.NoError(f.Store.EnsureSeededRelationshipTypes()) + + after, err := f.Store.GetRelationshipTypeBySlugContext(ctx, "friend") + require.NoError(err) + // User-owned columns survive; structural drift is repaired. + assert.Equal("mate", after.ForwardLabel, "re-seeding must not overwrite a user's label edit") + assert.Equal("mate", after.ReverseLabel) + assert.Equal(friend.UniversalID, after.UniversalID, "universal ID is identity, never rewritten") + assert.Equal("friend", after.Slug, "slug is immutable") + assert.True(after.IsSymmetric, "structural drift must be repaired") + require.NotNil(after.VCardRelatedType) + assert.Equal("friend", *after.VCardRelatedType) + assert.False(after.IsDeletable) + assert.Equal(updated.Revision+1, after.Revision, "one repair advances the revision once") + + // A second run finds nothing to do, so it must not bump the revision. + require.NoError(f.Store.EnsureSeededRelationshipTypes()) + settled, err := f.Store.GetRelationshipTypeBySlugContext(ctx, "friend") + require.NoError(err) + assert.Equal(after.Revision, settled.Revision, "a no-op reconcile must not write") + assert.Equal(after.UpdatedAt, settled.UpdatedAt) + + types, err := f.Store.ListRelationshipTypesContext(ctx) + require.NoError(err) + assert.Len(types, 19, "re-seeding must not duplicate rows") +} + +func TestInitSchemaPreservesUserRemappedSeededVCardType(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + + friend, err := f.Store.GetRelationshipTypeBySlugContext(ctx, "friend") + require.NoError(err) + + // A user may release a seeded mapping and assign the registered RELATED + // TYPE to a user-owned type. The unique index then prevents the old seed + // value from being restored on the next startup. + cleared := "" + clearedFriend, err := f.Store.UpdateRelationshipTypeContext(ctx, friend.ID, friend.Revision, + store.RelationshipTypeUpdate{VCardRelatedType: &cleared}) + require.NoError(err) + assert.Nil(clearedFriend.VCardRelatedType) + + relatedType := "friend" + alias, err := f.Store.CreateRelationshipTypeContext(ctx, store.RelationshipTypeInput{ + Slug: "friend-alias", ForwardLabel: "friend alias", ReverseLabel: "friend alias", + IsSymmetric: true, VCardRelatedType: &relatedType, + }) + require.NoError(err) + + // InitSchema is the production startup path. It must not restore the + // original seed mapping or fail on the unique constraint. + require.NoError(f.Store.InitSchema(), "a later startup must preserve the remapping") + + friendAfter, err := f.Store.GetRelationshipTypeBySlugContext(ctx, "friend") + require.NoError(err) + assert.Nil(friendAfter.VCardRelatedType, "clearing a seeded mapping must persist") + aliasAfter, err := f.Store.GetRelationshipTypeContext(ctx, alias.ID) + require.NoError(err) + require.NotNil(aliasAfter.VCardRelatedType) + assert.Equal("friend", *aliasAfter.VCardRelatedType) + + // The remapped TYPE still resolves through the production RELATED import + // path, so preserving the user choice keeps its intended semantics. + aliceID, bobID := mustTwoPersons(t, f) + bob, err := f.Store.GetPerson(bobID) + require.NoError(err) + resolution, err := f.Store.ResolveRelatedValueContext(ctx, store.RelatedImport{ + PersonID: aliceID, RawValue: bob.VCardUID, RawType: "friend", + ValueKind: store.RelatedValueKindURI, Source: store.ProvenanceVCardImport, Actor: "system", + }) + require.NoError(err) + require.NotNil(resolution.Relationship) + assert.Equal(alias.ID, resolution.Relationship.RelationshipTypeID) + assert.Equal("friend-alias", resolution.Relationship.TypeSlug) +} + +func TestEnsureSeededRelationshipTypesRestoresADeletedSeed(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + + // A partially seeded or hand-edited database self-heals, which is why the + // reconciler runs on every open instead of behind an applied_migrations + // gate that can only ever fire once. + _, err := f.Store.DB().Exec(f.Store.Rebind( + `DELETE FROM relationship_types WHERE slug = ?`), "neighbor") + require.NoError(err) + + require.NoError(f.Store.EnsureSeededRelationshipTypes()) + + restored, err := f.Store.GetRelationshipTypeBySlugContext(ctx, "neighbor") + require.NoError(err) + assert.Equal("neighbor", restored.ForwardLabel) + assert.Equal(store.RelationshipTypeOwnershipSystem, restored.Ownership) + assert.False(restored.IsDeletable) + + types, err := f.Store.ListRelationshipTypesContext(ctx) + require.NoError(err) + assert.Len(types, 19) +} + +func TestEnsureSeededRelationshipTypesRepairsStructuralDriftButPreservesLabels(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + + friend, err := f.Store.GetRelationshipTypeBySlugContext(ctx, "friend") + require.NoError(err) + + // Labels and vCard mappings are mutable presentation/interchange metadata, + // while these three fields are structural. + // The stale timestamp makes a successful repair observable on SQLite too, + // whose datetime('now') has second precision. + _, err = f.Store.DB().Exec(f.Store.Rebind(` + UPDATE relationship_types + SET forward_label = ?, reverse_label = ?, is_symmetric = FALSE, + is_deletable = TRUE, + updated_at = '2000-01-01 00:00:00' + WHERE id = ? + `), "mate", "mate", friend.ID) + require.NoError(err) + + corrupted, err := f.Store.GetRelationshipTypeContext(ctx, friend.ID) + require.NoError(err) + require.False(corrupted.IsSymmetric) + require.NotNil(corrupted.VCardRelatedType) + assert.Equal("friend", *corrupted.VCardRelatedType) + require.True(corrupted.IsDeletable) + + require.NoError(f.Store.EnsureSeededRelationshipTypes()) + + repaired, err := f.Store.GetRelationshipTypeContext(ctx, friend.ID) + require.NoError(err) + assert.Equal("mate", repaired.ForwardLabel) + assert.Equal("mate", repaired.ReverseLabel) + assert.True(repaired.IsSymmetric) + require.NotNil(repaired.VCardRelatedType) + assert.Equal("friend", *repaired.VCardRelatedType) + assert.False(repaired.IsDeletable) + assert.Equal(corrupted.Revision+1, repaired.Revision) + assert.NotEqual(corrupted.UpdatedAt, repaired.UpdatedAt) + + require.NoError(f.Store.EnsureSeededRelationshipTypes()) + settled, err := f.Store.GetRelationshipTypeContext(ctx, friend.ID) + require.NoError(err) + assert.Equal(repaired.Revision, settled.Revision) + assert.Equal(repaired.UpdatedAt, settled.UpdatedAt) +} + +func TestEnsureSeededRelationshipTypesRepairsInverseDriftWithRevision(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + + parent, err := f.Store.GetRelationshipTypeBySlugContext(ctx, "parent") + require.NoError(err) + child, err := f.Store.GetRelationshipTypeBySlugContext(ctx, "child") + require.NoError(err) + + _, err = f.Store.DB().Exec(f.Store.Rebind(` + UPDATE relationship_types + SET inverse_type_id = NULL, updated_at = '2000-01-01 00:00:00' + WHERE id = ? + `), parent.ID) + require.NoError(err) + + corrupted, err := f.Store.GetRelationshipTypeContext(ctx, parent.ID) + require.NoError(err) + require.Nil(corrupted.InverseTypeID) + + require.NoError(f.Store.EnsureSeededRelationshipTypes()) + + repaired, err := f.Store.GetRelationshipTypeContext(ctx, parent.ID) + require.NoError(err) + require.NotNil(repaired.InverseTypeID) + assert.Equal(child.ID, *repaired.InverseTypeID) + assert.Equal(corrupted.Revision+1, repaired.Revision) + assert.NotEqual(corrupted.UpdatedAt, repaired.UpdatedAt) + + require.NoError(f.Store.EnsureSeededRelationshipTypes()) + settled, err := f.Store.GetRelationshipTypeContext(ctx, parent.ID) + require.NoError(err) + assert.Equal(repaired.Revision, settled.Revision) + assert.Equal(repaired.UpdatedAt, settled.UpdatedAt) +} + +func TestCreateRelationshipTypeAssignsUUIDUniversalIDAndUserOwnership(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + + color := "#336699" + created, err := f.Store.CreateRelationshipTypeContext(ctx, store.RelationshipTypeInput{ + Slug: "mentor", + ForwardLabel: "mentor", + ReverseLabel: "mentee", + Color: &color, + }) + require.NoError(err) + assert.Equal("mentor", created.Slug) + assert.Equal("mentor", created.ForwardLabel) + assert.Equal("mentee", created.ReverseLabel) + assert.False(created.IsSymmetric) + assert.True(created.IsCanonical, "user types are always their own canonical orientation") + assert.Nil(created.InverseTypeID) + assert.Equal(store.RelationshipTypeOwnershipUser, created.Ownership) + assert.True(created.IsDeletable) + assert.Equal(int64(1), created.Revision) + assert.Nil(created.VCardRelatedType) + require.NotNil(created.Color) + assert.Equal("#336699", *created.Color) + // A user type gets an opaque random universal ID, never the reserved + // deterministic prefix used by seeded types. + assert.Regexp(`^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$`, created.UniversalID) + + _, err = f.Store.CreateRelationshipTypeContext(ctx, store.RelationshipTypeInput{ + Slug: "mentor", ForwardLabel: "guide", ReverseLabel: "guided", + }) + require.ErrorIs(err, store.ErrRelationshipTypeSlugConflict) + + _, err = f.Store.CreateRelationshipTypeContext(ctx, store.RelationshipTypeInput{ + Slug: "friend", ForwardLabel: "friend", ReverseLabel: "friend", IsSymmetric: true, + }) + require.ErrorIs(err, store.ErrRelationshipTypeSlugConflict, + "a user type must not shadow a seeded slug") +} + +func TestCreateRelationshipTypeValidatesSlugSymmetryAndRelatedType(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + + for _, slug := range []string{ + "", " ", "Mentor", "co worker", "co_worker", "-lead", "lead-", + "co--worker", "über", "a/b", + } { + _, err := f.Store.CreateRelationshipTypeContext(ctx, store.RelationshipTypeInput{ + Slug: slug, ForwardLabel: "a", ReverseLabel: "b", + }) + require.ErrorIsf(err, store.ErrRelationshipTypeInvalid, "slug %q must be rejected", slug) + } + + _, err := f.Store.CreateRelationshipTypeContext(ctx, store.RelationshipTypeInput{ + Slug: "buddy", ForwardLabel: "buddy", ReverseLabel: "pal", IsSymmetric: true, + }) + require.ErrorIs(err, store.ErrRelationshipTypeSymmetricLabels) + + unregistered := "best-friend-forever" + _, err = f.Store.CreateRelationshipTypeContext(ctx, store.RelationshipTypeInput{ + Slug: "bff", ForwardLabel: "bff", ReverseLabel: "bff", IsSymmetric: true, + VCardRelatedType: &unregistered, + }) + require.ErrorIs(err, store.ErrRelationshipTypeInvalid, + "vcard_related_type must be a registered RELATED TYPE value") + + // "work" and "home" are legal TYPE values on RELATED via RFC 6350's + // shared multi-property parameter row, but they qualify the context of a + // relation rather than naming one, so they cannot be a relationship type's + // vCard mapping. See relatedTypeValues for the full rationale. + for _, contextual := range []string{"work", "home"} { + value := contextual + _, err := f.Store.CreateRelationshipTypeContext(ctx, store.RelationshipTypeInput{ + Slug: "at-" + value, ForwardLabel: value, ReverseLabel: value, IsSymmetric: true, + VCardRelatedType: &value, + }) + require.ErrorIsf(err, store.ErrRelationshipTypeInvalid, + "%q qualifies a relation's context and must not name a relationship type", value) + } + + taken := "friend" + _, err = f.Store.CreateRelationshipTypeContext(ctx, store.RelationshipTypeInput{ + Slug: "pal", ForwardLabel: "pal", ReverseLabel: "pal", IsSymmetric: true, + VCardRelatedType: &taken, + }) + require.ErrorIs(err, store.ErrRelationshipTypeRelatedTypeConflict) + + // 'me' is registered but deliberately unseeded, so it is still available. + free := "me" + created, err := f.Store.CreateRelationshipTypeContext(ctx, store.RelationshipTypeInput{ + Slug: "same-person", ForwardLabel: "same person", ReverseLabel: "same person", + IsSymmetric: true, VCardRelatedType: &free, + }) + require.NoError(err) + assert.Equal("me", *created.VCardRelatedType) +} + +func TestUpdateRelationshipTypeEditsPresentationAndRejectsStaleRevision(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + + created, err := f.Store.CreateRelationshipTypeContext(ctx, store.RelationshipTypeInput{ + Slug: "mentor", ForwardLabel: "mentor", ReverseLabel: "mentee", + }) + require.NoError(err) + + forward := "coach" + icon := "whistle" + updated, err := f.Store.UpdateRelationshipTypeContext(ctx, created.ID, created.Revision, + store.RelationshipTypeUpdate{ForwardLabel: &forward, Icon: &icon}) + require.NoError(err) + assert.Equal("coach", updated.ForwardLabel) + assert.Equal("mentee", updated.ReverseLabel, "an unset field is left unchanged") + require.NotNil(updated.Icon) + assert.Equal("whistle", *updated.Icon) + assert.Equal(created.Revision+1, updated.Revision) + assert.Equal(created.Slug, updated.Slug, "slug is immutable") + assert.Equal(created.UniversalID, updated.UniversalID, "universal ID is immutable") + + cleared := "" + afterClear, err := f.Store.UpdateRelationshipTypeContext(ctx, updated.ID, updated.Revision, + store.RelationshipTypeUpdate{Icon: &cleared}) + require.NoError(err) + assert.Nil(afterClear.Icon, "a pointer to the empty string clears the value") + + _, err = f.Store.UpdateRelationshipTypeContext(ctx, created.ID, created.Revision, + store.RelationshipTypeUpdate{ForwardLabel: &forward}) + require.ErrorIs(err, store.ErrRelationshipTypeRevisionConflict) + + _, err = f.Store.UpdateRelationshipTypeContext(ctx, 999999, 1, + store.RelationshipTypeUpdate{ForwardLabel: &forward}) + require.ErrorIs(err, store.ErrRelationshipTypeNotFound) +} + +func TestRelationshipTypeVCardMappingNormalizesAndClears(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + rawRelatedType := " ME " + + created, err := f.Store.CreateRelationshipTypeContext(ctx, store.RelationshipTypeInput{ + Slug: "same-person", ForwardLabel: "same person", ReverseLabel: "same person", + IsSymmetric: true, VCardRelatedType: &rawRelatedType, + }) + require.NoError(err) + require.NotNil(created.VCardRelatedType) + assert.Equal("me", *created.VCardRelatedType) + + clearedMapping := "" + updated, err := f.Store.UpdateRelationshipTypeContext(ctx, created.ID, created.Revision, + store.RelationshipTypeUpdate{VCardRelatedType: &clearedMapping}) + require.NoError(err) + assert.Nil(updated.VCardRelatedType) +} + +func TestUpdateRelationshipTypeKeepsSymmetricLabelsIdentical(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + + spouse, err := f.Store.GetRelationshipTypeBySlugContext(ctx, "spouse") + require.NoError(err) + + forwardOnly := "partner" + _, err = f.Store.UpdateRelationshipTypeContext(ctx, spouse.ID, spouse.Revision, + store.RelationshipTypeUpdate{ForwardLabel: &forwardOnly}) + require.ErrorIs(err, store.ErrRelationshipTypeSymmetricLabels, + "editing one label of a symmetric type would make the two directions disagree") + + updated, err := f.Store.UpdateRelationshipTypeContext(ctx, spouse.ID, spouse.Revision, + store.RelationshipTypeUpdate{ForwardLabel: &forwardOnly, ReverseLabel: &forwardOnly}) + require.NoError(err) + assert.Equal("partner", updated.ForwardLabel) + assert.Equal("partner", updated.ReverseLabel) +} + +func TestDeleteRelationshipTypeProtectsSystemTypesAndTypesInUse(t *testing.T) { + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + + friend, err := f.Store.GetRelationshipTypeBySlugContext(ctx, "friend") + require.NoError(err) + require.ErrorIs(f.Store.DeleteRelationshipTypeContext(ctx, friend.ID, friend.Revision), store.ErrRelationshipTypeNotDeletable) + + created, err := f.Store.CreateRelationshipTypeContext(ctx, store.RelationshipTypeInput{Slug: "mentor", ForwardLabel: "mentor", ReverseLabel: "mentee"}) + require.NoError(err) + alice, bob := mustTwoPersons(t, f) + _, err = f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{SourcePersonID: alice, TargetPersonID: bob, TypeSlug: "mentor", Source: store.ProvenanceUser, Actor: "user"}) + require.NoError(err) + require.ErrorIs(f.Store.DeleteRelationshipTypeContext(ctx, created.ID, created.Revision), store.ErrRelationshipTypeInUse) + + unused, err := f.Store.CreateRelationshipTypeContext(ctx, store.RelationshipTypeInput{ + Slug: "rival", ForwardLabel: "rival", ReverseLabel: "rival", IsSymmetric: true, + }) + require.NoError(err) + require.ErrorIs(f.Store.DeleteRelationshipTypeContext(ctx, unused.ID, unused.Revision-1), store.ErrRelationshipTypeRevisionConflict) + require.NoError(f.Store.DeleteRelationshipTypeContext(ctx, unused.ID, unused.Revision)) + _, err = f.Store.GetRelationshipTypeContext(ctx, unused.ID) + require.ErrorIs(err, store.ErrRelationshipTypeNotFound) +} diff --git a/internal/store/schema.sql b/internal/store/schema.sql index e7f306cf4..b5f65a0a0 100644 --- a/internal/store/schema.sql +++ b/internal/store/schema.sql @@ -690,6 +690,197 @@ CREATE TABLE IF NOT EXISTS participant_links ( CREATE INDEX IF NOT EXISTS idx_participant_links_b ON participant_links(participant_b); +-- ============================================================================ +-- PERSON RELATIONSHIPS +-- ============================================================================ + +-- Relationship type metadata. A type is presentation and interchange +-- metadata, never a second copy of an edge: forward_label and reverse_label +-- let ONE person_relationships row render correctly from both endpoints, so +-- there is no mirrored row that can drift (the failure mode seen in personal +-- CRMs that store both directions). +-- +-- Label contract. A person_relationships row asserts exactly one sentence: +-- +-- is the of . +-- +-- The inverse sentence is implied by the same row: +-- +-- is the of . +-- +-- Worked example. Type 'parent' has forward_label 'parent' and reverse_label +-- 'child'. The row (source=alice, target=bob, type=parent) asserts "alice is +-- the parent of bob". Alice's relationship list shows "bob - child"; Bob's +-- shows "alice - parent". One row, two correct labels. +-- +-- slug and universal_id are immutable machine identity; labels, colour, icon, +-- and description are mutable presentation. universal_id is an OPAQUE UUID, +-- hardcoded per seeded type so the same type has the same identity in every +-- install (which keeps exports and API clients portable) and minted randomly +-- for user-created types. It is deliberately not derived from the slug: a +-- derived identifier would make the slug load-bearing for identity, so the +-- slug would stop being independent and the two columns would collapse into +-- one immutable string wearing two names. +-- +-- is_symmetric types (friend, spouse, sibling) need no orientation: writes +-- normalize the endpoints to (lower id, higher id) so the unordered pair has +-- one representation and the active-edge unique index rejects the mirror. +-- +-- is_canonical/inverse_type_id exist because the IANA RELATED registry +-- contains one genuine inverse PAIR: 'parent' and 'child'. Both values must +-- map for lossless vCard interchange, but they describe the same edge from +-- opposite ends. 'child' is therefore marked non-canonical and points at +-- 'parent'; a write using 'child' is stored as 'parent' with the endpoints +-- swapped. Without this, (bob child-of alice) and (alice parent-of bob) would +-- both be storable and the duplicate-active-edge rule would not hold. +-- +-- vcard_related_type is mutable interchange metadata and UNIQUE (NULLs are +-- distinct in both backends) so each registered RELATED TYPE value resolves +-- to exactly one type on import. Startup seed reconciliation preserves a +-- user's mapping choice instead of restoring the original seed value. +-- +-- ownership is TEXT ('system' | 'user') rather than an is_system boolean, and +-- carries no CHECK: the roadmap leaves room for a third ownership kind such as +-- vendor or plugin, and widening a TEXT vocabulary needs no SQLite table +-- rebuild whereas a boolean would need a new column. It is validated in Go so +-- both backends reject the same values. +CREATE TABLE IF NOT EXISTS relationship_types ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + universal_id TEXT NOT NULL UNIQUE, + slug TEXT NOT NULL UNIQUE, + forward_label TEXT NOT NULL, + reverse_label TEXT NOT NULL, + is_symmetric BOOLEAN NOT NULL DEFAULT FALSE, + is_canonical BOOLEAN NOT NULL DEFAULT TRUE, + inverse_type_id INTEGER REFERENCES relationship_types(id) ON DELETE SET NULL, + vcard_related_type TEXT UNIQUE, + color TEXT, + icon TEXT, + description TEXT, + ownership TEXT NOT NULL DEFAULT 'user', + is_deletable BOOLEAN NOT NULL DEFAULT TRUE, + revision INTEGER NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (is_symmetric = FALSE OR forward_label = reverse_label), + CHECK (is_canonical = TRUE OR inverse_type_id IS NOT NULL) +); + +-- One canonical person-to-person relationship edge. The row asserts +-- "source_person_id is the type's forward_label of target_person_id"; the +-- reverse label renders the same row from the other endpoint. Non-canonical +-- inverse types are rewritten by the writer and symmetric types order their +-- endpoints, so there is never a second mirror row. +-- +-- start_year/month/day and end_year/month/day store nullable partial-date +-- components. A bound's precision degrades year -> month -> day, and every +-- present relationship bound must include a year. The store validates calendar +-- dates and compares bounds at shared precision; the CHECKs make the portable +-- component shape and range true even for writers that bypass Go. +-- +-- start_* / end_* are world time, while created_at / updated_at are transaction +-- time. Ending fills end_* and retains history; the partial unique index allows +-- a new row only after the earlier edge is no longer active. +CREATE TABLE IF NOT EXISTS person_relationships ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_person_id INTEGER NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + target_person_id INTEGER NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + relationship_type_id INTEGER NOT NULL REFERENCES relationship_types(id), + start_year INTEGER, + start_month INTEGER, + start_day INTEGER, + end_year INTEGER, + end_month INTEGER, + end_day INTEGER, + status TEXT NOT NULL DEFAULT 'active', + notes TEXT, + source TEXT NOT NULL DEFAULT 'user', + source_ref TEXT, + confidence REAL, + vcard_property TEXT, + vcard_group TEXT, + vcard_prop_id TEXT, + vcard_pid TEXT, + vcard_altid TEXT, + created_by TEXT NOT NULL DEFAULT 'user', + updated_by TEXT NOT NULL DEFAULT 'user', + revision INTEGER NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (source_person_id <> target_person_id), + CHECK (start_year BETWEEN 1 AND 9999), + CHECK (start_month BETWEEN 1 AND 12), + CHECK (start_day BETWEEN 1 AND 31), + CHECK (end_year BETWEEN 1 AND 9999), + CHECK (end_month BETWEEN 1 AND 12), + CHECK (end_day BETWEEN 1 AND 31), + CHECK (start_day IS NULL OR start_month IS NOT NULL), + CHECK (end_day IS NULL OR end_month IS NOT NULL), + CHECK (start_month IS NULL OR start_year IS NOT NULL), + CHECK (end_month IS NULL OR end_year IS NOT NULL), + CHECK (source IN ('user', 'carddav_import', 'vcard_import', + 'archive_observation', 'extraction', 'enrichment', + 'system')), + CHECK (confidence IS NULL OR (confidence >= 0 AND confidence <= 1 + AND source NOT IN ('user', 'carddav_import', 'vcard_import'))) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_person_relationships_active_unique + ON person_relationships(source_person_id, target_person_id, relationship_type_id) + WHERE end_year IS NULL; +CREATE INDEX IF NOT EXISTS idx_person_relationships_source + ON person_relationships(source_person_id); +CREATE INDEX IF NOT EXISTS idx_person_relationships_target + ON person_relationships(target_person_id); +CREATE INDEX IF NOT EXISTS idx_person_relationships_target_active + ON person_relationships(target_person_id, relationship_type_id) + WHERE end_year IS NULL; +CREATE INDEX IF NOT EXISTS idx_person_relationships_type + ON person_relationships(relationship_type_id); + +-- Imported vCard RELATED values that did not automatically resolve to a +-- curated person relationship. Exact UID is the only automatic identity +-- match; all other imported assertions stay here for a human decision. +CREATE TABLE IF NOT EXISTS person_relationship_reviews ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + person_id INTEGER NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + raw_related_value TEXT NOT NULL, + raw_related_type TEXT NOT NULL DEFAULT '', + value_kind TEXT NOT NULL, + matched_person_id INTEGER REFERENCES persons(id) ON DELETE SET NULL, + accepted_relationship_id INTEGER REFERENCES person_relationships(id) ON DELETE SET NULL, + status TEXT NOT NULL DEFAULT 'pending', + source TEXT NOT NULL, + source_ref TEXT, + vcard_property TEXT, + vcard_group TEXT, + vcard_prop_id TEXT, + vcard_pid TEXT, + vcard_altid TEXT, + created_by TEXT NOT NULL DEFAULT 'system', + reviewed_by TEXT, + reviewed_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (matched_person_id IS NULL OR matched_person_id <> person_id), + CHECK (source IN ('user', 'carddav_import', 'vcard_import', + 'archive_observation', 'extraction', 'enrichment', + 'system')) +); + +-- One review per parsed property occurrence. COALESCE makes the nullable +-- provenance/property identity fields participate in uniqueness identically +-- on SQLite and PostgreSQL while preserving exact re-import idempotency. +CREATE UNIQUE INDEX IF NOT EXISTS idx_person_relationship_reviews_occurrence_unique + ON person_relationship_reviews( + person_id, raw_related_type, raw_related_value, source, + COALESCE(source_ref, ''), COALESCE(vcard_property, ''), + COALESCE(vcard_group, ''), COALESCE(vcard_prop_id, ''), + COALESCE(vcard_pid, ''), COALESCE(vcard_altid, '') + ); +CREATE INDEX IF NOT EXISTS idx_person_relationship_reviews_status + ON person_relationship_reviews(status, person_id, id); + -- ============================================================================ -- PORTABLE FIELD METADATA AND TYPED ATTRIBUTES -- ============================================================================ diff --git a/internal/store/schema_pg.sql b/internal/store/schema_pg.sql index e220b5e81..2ca0018f4 100644 --- a/internal/store/schema_pg.sql +++ b/internal/store/schema_pg.sql @@ -477,6 +477,197 @@ CREATE TABLE IF NOT EXISTS participant_links ( CREATE INDEX IF NOT EXISTS idx_participant_links_b ON participant_links(participant_b); +-- ============================================================================ +-- PERSON RELATIONSHIPS +-- ============================================================================ + +-- Relationship type metadata. A type is presentation and interchange +-- metadata, never a second copy of an edge: forward_label and reverse_label +-- let ONE person_relationships row render correctly from both endpoints, so +-- there is no mirrored row that can drift (the failure mode seen in personal +-- CRMs that store both directions). +-- +-- Label contract. A person_relationships row asserts exactly one sentence: +-- +-- is the of . +-- +-- The inverse sentence is implied by the same row: +-- +-- is the of . +-- +-- Worked example. Type 'parent' has forward_label 'parent' and reverse_label +-- 'child'. The row (source=alice, target=bob, type=parent) asserts "alice is +-- the parent of bob". Alice's relationship list shows "bob - child"; Bob's +-- shows "alice - parent". One row, two correct labels. +-- +-- slug and universal_id are immutable machine identity; labels, colour, icon, +-- and description are mutable presentation. universal_id is an OPAQUE UUID, +-- hardcoded per seeded type so the same type has the same identity in every +-- install (which keeps exports and API clients portable) and minted randomly +-- for user-created types. It is deliberately not derived from the slug: a +-- derived identifier would make the slug load-bearing for identity, so the +-- slug would stop being independent and the two columns would collapse into +-- one immutable string wearing two names. +-- +-- is_symmetric types (friend, spouse, sibling) need no orientation: writes +-- normalize the endpoints to (lower id, higher id) so the unordered pair has +-- one representation and the active-edge unique index rejects the mirror. +-- +-- is_canonical/inverse_type_id exist because the IANA RELATED registry +-- contains one genuine inverse PAIR: 'parent' and 'child'. Both values must +-- map for lossless vCard interchange, but they describe the same edge from +-- opposite ends. 'child' is therefore marked non-canonical and points at +-- 'parent'; a write using 'child' is stored as 'parent' with the endpoints +-- swapped. Without this, (bob child-of alice) and (alice parent-of bob) would +-- both be storable and the duplicate-active-edge rule would not hold. +-- +-- vcard_related_type is mutable interchange metadata and UNIQUE (NULLs are +-- distinct in both backends) so each registered RELATED TYPE value resolves +-- to exactly one type on import. Startup seed reconciliation preserves a +-- user's mapping choice instead of restoring the original seed value. +-- +-- ownership is TEXT ('system' | 'user') rather than an is_system boolean, and +-- carries no CHECK: the roadmap leaves room for a third ownership kind such as +-- vendor or plugin, and widening a TEXT vocabulary needs no SQLite table +-- rebuild whereas a boolean would need a new column. It is validated in Go so +-- both backends reject the same values. +CREATE TABLE IF NOT EXISTS relationship_types ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + universal_id TEXT NOT NULL UNIQUE, + slug TEXT NOT NULL UNIQUE, + forward_label TEXT NOT NULL, + reverse_label TEXT NOT NULL, + is_symmetric BOOLEAN NOT NULL DEFAULT FALSE, + is_canonical BOOLEAN NOT NULL DEFAULT TRUE, + inverse_type_id BIGINT REFERENCES relationship_types(id) ON DELETE SET NULL, + vcard_related_type TEXT UNIQUE, + color TEXT, + icon TEXT, + description TEXT, + ownership TEXT NOT NULL DEFAULT 'user', + is_deletable BOOLEAN NOT NULL DEFAULT TRUE, + revision BIGINT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (is_symmetric = FALSE OR forward_label = reverse_label), + CHECK (is_canonical = TRUE OR inverse_type_id IS NOT NULL) +); + +-- One canonical person-to-person relationship edge. The row asserts +-- "source_person_id is the type's forward_label of target_person_id"; the +-- reverse label renders the same row from the other endpoint. Non-canonical +-- inverse types are rewritten by the writer and symmetric types order their +-- endpoints, so there is never a second mirror row. +-- +-- start_year/month/day and end_year/month/day store nullable partial-date +-- components. A bound's precision degrades year -> month -> day, and every +-- present relationship bound must include a year. The store validates calendar +-- dates and compares bounds at shared precision; the CHECKs make the portable +-- component shape and range true even for writers that bypass Go. +-- +-- start_* / end_* are world time, while created_at / updated_at are transaction +-- time. Ending fills end_* and retains history; the partial unique index allows +-- a new row only after the earlier edge is no longer active. +CREATE TABLE IF NOT EXISTS person_relationships ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + source_person_id BIGINT NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + target_person_id BIGINT NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + relationship_type_id BIGINT NOT NULL REFERENCES relationship_types(id), + start_year INTEGER, + start_month INTEGER, + start_day INTEGER, + end_year INTEGER, + end_month INTEGER, + end_day INTEGER, + status TEXT NOT NULL DEFAULT 'active', + notes TEXT, + source TEXT NOT NULL DEFAULT 'user', + source_ref TEXT, + confidence DOUBLE PRECISION, + vcard_property TEXT, + vcard_group TEXT, + vcard_prop_id TEXT, + vcard_pid TEXT, + vcard_altid TEXT, + created_by TEXT NOT NULL DEFAULT 'user', + updated_by TEXT NOT NULL DEFAULT 'user', + revision BIGINT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (source_person_id <> target_person_id), + CHECK (start_year BETWEEN 1 AND 9999), + CHECK (start_month BETWEEN 1 AND 12), + CHECK (start_day BETWEEN 1 AND 31), + CHECK (end_year BETWEEN 1 AND 9999), + CHECK (end_month BETWEEN 1 AND 12), + CHECK (end_day BETWEEN 1 AND 31), + CHECK (start_day IS NULL OR start_month IS NOT NULL), + CHECK (end_day IS NULL OR end_month IS NOT NULL), + CHECK (start_month IS NULL OR start_year IS NOT NULL), + CHECK (end_month IS NULL OR end_year IS NOT NULL), + CHECK (source IN ('user', 'carddav_import', 'vcard_import', + 'archive_observation', 'extraction', 'enrichment', + 'system')), + CHECK (confidence IS NULL OR (confidence >= 0 AND confidence <= 1 + AND source NOT IN ('user', 'carddav_import', 'vcard_import'))) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_person_relationships_active_unique + ON person_relationships(source_person_id, target_person_id, relationship_type_id) + WHERE end_year IS NULL; +CREATE INDEX IF NOT EXISTS idx_person_relationships_source + ON person_relationships(source_person_id); +CREATE INDEX IF NOT EXISTS idx_person_relationships_target + ON person_relationships(target_person_id); +CREATE INDEX IF NOT EXISTS idx_person_relationships_target_active + ON person_relationships(target_person_id, relationship_type_id) + WHERE end_year IS NULL; +CREATE INDEX IF NOT EXISTS idx_person_relationships_type + ON person_relationships(relationship_type_id); + +-- Imported vCard RELATED values that did not automatically resolve to a +-- curated person relationship. Exact UID is the only automatic identity +-- match; all other imported assertions stay here for a human decision. +CREATE TABLE IF NOT EXISTS person_relationship_reviews ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + person_id BIGINT NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + raw_related_value TEXT NOT NULL, + raw_related_type TEXT NOT NULL DEFAULT '', + value_kind TEXT NOT NULL, + matched_person_id BIGINT REFERENCES persons(id) ON DELETE SET NULL, + accepted_relationship_id BIGINT REFERENCES person_relationships(id) ON DELETE SET NULL, + status TEXT NOT NULL DEFAULT 'pending', + source TEXT NOT NULL, + source_ref TEXT, + vcard_property TEXT, + vcard_group TEXT, + vcard_prop_id TEXT, + vcard_pid TEXT, + vcard_altid TEXT, + created_by TEXT NOT NULL DEFAULT 'system', + reviewed_by TEXT, + reviewed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (matched_person_id IS NULL OR matched_person_id <> person_id), + CHECK (source IN ('user', 'carddav_import', 'vcard_import', + 'archive_observation', 'extraction', 'enrichment', + 'system')) +); + +-- One review per parsed property occurrence. COALESCE makes the nullable +-- provenance/property identity fields participate in uniqueness identically +-- on SQLite and PostgreSQL while preserving exact re-import idempotency. +CREATE UNIQUE INDEX IF NOT EXISTS idx_person_relationship_reviews_occurrence_unique + ON person_relationship_reviews( + person_id, raw_related_type, raw_related_value, source, + COALESCE(source_ref, ''), COALESCE(vcard_property, ''), + COALESCE(vcard_group, ''), COALESCE(vcard_prop_id, ''), + COALESCE(vcard_pid, ''), COALESCE(vcard_altid, '') + ); +CREATE INDEX IF NOT EXISTS idx_person_relationship_reviews_status + ON person_relationship_reviews(status, person_id, id); + -- ============================================================================ -- PORTABLE FIELD METADATA AND TYPED ATTRIBUTES -- ============================================================================ diff --git a/internal/store/store.go b/internal/store/store.go index 6fe76cb4a..3e58b386e 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1540,6 +1540,14 @@ func (s *Store) InitSchemaContext(ctx context.Context) error { return fmt.Errorf("ensure seeded attribute definitions: %w", err) } + // Reconcile the system relationship type catalog on every open: insert + // missing seeds, repair structural drift, and leave user-owned labels, + // vCard mappings, colours, icons, and descriptions alone. See + // EnsureSeededRelationshipTypes for the two column classes. + if err := s.EnsureSeededRelationshipTypesContext(ctx); err != nil { + return err + } + // Ensure the default "All" collection exists and contains every source. if err := s.EnsureDefaultCollectionContext(ctx); err != nil { return fmt.Errorf("ensure default collection: %w", err) diff --git a/pkg/client/generated/client.go b/pkg/client/generated/client.go index 4a10f6cd4..1eb5836cf 100644 --- a/pkg/client/generated/client.go +++ b/pkg/client/generated/client.go @@ -411,6 +411,26 @@ type ClientInterface interface { GetPersonTimeline(ctx context.Context, options *GetPersonTimelineRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonTimelineResponse, error) GetPersonTimelineWithResponse(ctx context.Context, options *GetPersonTimelineRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonTimelineResp, error) + // ListPersonRelationshipReviews List imported RELATED values awaiting review + ListPersonRelationshipReviews(ctx context.Context, options *ListPersonRelationshipReviewsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonRelationshipReviewsResponse, error) + ListPersonRelationshipReviewsWithResponse(ctx context.Context, options *ListPersonRelationshipReviewsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonRelationshipReviewsResp, error) + + // CreatePersonRelationship Declare a relationship between two persons + CreatePersonRelationship(ctx context.Context, options *CreatePersonRelationshipRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CreatePersonRelationshipResponse, error) + CreatePersonRelationshipWithResponse(ctx context.Context, options *CreatePersonRelationshipRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CreatePersonRelationshipResp, error) + + // DeletePersonRelationship Delete a person relationship + DeletePersonRelationship(ctx context.Context, options *DeletePersonRelationshipRequestOptions, reqEditors ...runtime.RequestEditorFn) (*struct{}, error) + DeletePersonRelationshipWithResponse(ctx context.Context, options *DeletePersonRelationshipRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DeletePersonRelationshipResp, error) + + // GetPersonRelationship Get one person relationship + GetPersonRelationship(ctx context.Context, options *GetPersonRelationshipRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonRelationshipResponse, error) + GetPersonRelationshipWithResponse(ctx context.Context, options *GetPersonRelationshipRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonRelationshipResp, error) + + // PatchPersonRelationship End a relationship or replace its notes + PatchPersonRelationship(ctx context.Context, options *PatchPersonRelationshipRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PatchPersonRelationshipResponse, error) + PatchPersonRelationshipWithResponse(ctx context.Context, options *PatchPersonRelationshipRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PatchPersonRelationshipResp, error) + // ListPersons List durable person profiles ListPersons(ctx context.Context, reqEditors ...runtime.RequestEditorFn) (*ListPersonsResponse, error) ListPersonsWithResponse(ctx context.Context, reqEditors ...runtime.RequestEditorFn) (*ListPersonsResp, error) @@ -459,10 +479,34 @@ type ClientInterface interface { GetPersonProfileMediaContent(ctx context.Context, options *GetPersonProfileMediaContentRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonProfileMediaContentResponse, error) GetPersonProfileMediaContentWithResponse(ctx context.Context, options *GetPersonProfileMediaContentRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonProfileMediaContentResp, error) + // ListPersonRelationships List one person's relationships + ListPersonRelationships(ctx context.Context, options *ListPersonRelationshipsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonRelationshipsResponse, error) + ListPersonRelationshipsWithResponse(ctx context.Context, options *ListPersonRelationshipsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonRelationshipsResp, 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) + // ListRelationshipTypes List person relationship types + ListRelationshipTypes(ctx context.Context, reqEditors ...runtime.RequestEditorFn) (*ListRelationshipTypesResponse, error) + ListRelationshipTypesWithResponse(ctx context.Context, reqEditors ...runtime.RequestEditorFn) (*ListRelationshipTypesResp, error) + + // CreateRelationshipType Create a user-owned relationship type + CreateRelationshipType(ctx context.Context, options *CreateRelationshipTypeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CreateRelationshipTypeResponse, error) + CreateRelationshipTypeWithResponse(ctx context.Context, options *CreateRelationshipTypeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CreateRelationshipTypeResp, error) + + // DeleteRelationshipType Delete an unused relationship type + DeleteRelationshipType(ctx context.Context, options *DeleteRelationshipTypeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*struct{}, error) + DeleteRelationshipTypeWithResponse(ctx context.Context, options *DeleteRelationshipTypeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DeleteRelationshipTypeResp, error) + + // GetRelationshipType Get a relationship type + GetRelationshipType(ctx context.Context, options *GetRelationshipTypeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetRelationshipTypeResponse, error) + GetRelationshipTypeWithResponse(ctx context.Context, options *GetRelationshipTypeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetRelationshipTypeResp, error) + + // PatchRelationshipType Update a relationship type + PatchRelationshipType(ctx context.Context, options *PatchRelationshipTypeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PatchRelationshipTypeResponse, error) + PatchRelationshipTypeWithResponse(ctx context.Context, options *PatchRelationshipTypeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PatchRelationshipTypeResp, error) + // ListRelationships Rank counterparts by reciprocity-weighted interaction ListRelationships(ctx context.Context, options *ListRelationshipsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListRelationshipsResponse, error) ListRelationshipsWithResponse(ctx context.Context, options *ListRelationshipsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListRelationshipsResp, error) @@ -6401,12 +6445,13 @@ func (c *Client) GetPersonTimeline(ctx context.Context, options *GetPersonTimeli return responseParser(ctx, resp) } -// ListPersons List durable person profiles -func (c *Client) ListPersons(ctx context.Context, reqEditors ...runtime.RequestEditorFn) (*ListPersonsResponse, error) { +// ListPersonRelationshipReviews List imported RELATED values awaiting review +func (c *Client) ListPersonRelationshipReviews(ctx context.Context, options *ListPersonRelationshipReviewsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonRelationshipReviewsResponse, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/person-relationship-reviews", Method: "GET", + Options: options, } req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) @@ -6414,10 +6459,10 @@ func (c *Client) ListPersons(ctx context.Context, reqEditors ...runtime.RequestE return nil, fmt.Errorf("error creating request: %w", err) } - responseParser := func(ctx context.Context, resp *runtime.Response) (*ListPersonsResponse, error) { + responseParser := func(ctx context.Context, resp *runtime.Response) (*ListPersonRelationshipReviewsResponse, error) { bodyBytes := resp.Content if resp.StatusCode != 200 { - target := new(ListPersonsErrorResponse) + target := new(ListPersonRelationshipReviewsErrorResponse) // Handle empty error response body gracefully - skip unmarshal if no content if len(bodyBytes) > 0 { if err = json.Unmarshal(bodyBytes, target); err != nil { @@ -6425,7 +6470,7 @@ func (c *Client) ListPersons(ctx context.Context, reqEditors ...runtime.RequestE StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "ListPersonsErrorResponse", + TargetType: "ListPersonRelationshipReviewsErrorResponse", Body: bodyBytes, Err: err, } @@ -6438,7 +6483,7 @@ func (c *Client) ListPersons(ctx context.Context, reqEditors ...runtime.RequestE return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), runtime.WithStatusCode(resp.StatusCode)) } - target := new(ListPersonsResponse) + target := new(ListPersonRelationshipReviewsResponse) // Handle empty response body gracefully if len(bodyBytes) == 0 { return target, nil @@ -6448,7 +6493,7 @@ func (c *Client) ListPersons(ctx context.Context, reqEditors ...runtime.RequestE StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "ListPersonsResponse", + TargetType: "ListPersonRelationshipReviewsResponse", Body: bodyBytes, Err: err, } @@ -6456,18 +6501,18 @@ func (c *Client) ListPersons(ctx context.Context, reqEditors ...runtime.RequestE return target, nil } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/person-relationship-reviews") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } return responseParser(ctx, resp) } -// CreatePerson Promote a participant cluster to a durable person -func (c *Client) CreatePerson(ctx context.Context, options *CreatePersonRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CreatePersonResponseJSON, error) { +// CreatePersonRelationship Declare a relationship between two persons +func (c *Client) CreatePersonRelationship(ctx context.Context, options *CreatePersonRelationshipRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CreatePersonRelationshipResponse, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/person-relationships", Method: "POST", Options: options, ContentType: "application/json", @@ -6478,10 +6523,10 @@ func (c *Client) CreatePerson(ctx context.Context, options *CreatePersonRequestO return nil, fmt.Errorf("error creating request: %w", err) } - responseParser := func(ctx context.Context, resp *runtime.Response) (*CreatePersonResponseJSON, error) { + responseParser := func(ctx context.Context, resp *runtime.Response) (*CreatePersonRelationshipResponse, error) { bodyBytes := resp.Content if resp.StatusCode != 201 { - target := new(CreatePersonErrorResponse) + target := new(CreatePersonRelationshipErrorResponse) // Handle empty error response body gracefully - skip unmarshal if no content if len(bodyBytes) > 0 { if err = json.Unmarshal(bodyBytes, target); err != nil { @@ -6489,7 +6534,7 @@ func (c *Client) CreatePerson(ctx context.Context, options *CreatePersonRequestO StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "CreatePersonErrorResponse", + TargetType: "CreatePersonRelationshipErrorResponse", Body: bodyBytes, Err: err, } @@ -6502,7 +6547,7 @@ func (c *Client) CreatePerson(ctx context.Context, options *CreatePersonRequestO return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), runtime.WithStatusCode(resp.StatusCode)) } - target := new(CreatePersonResponseJSON) + target := new(CreatePersonRelationshipResponse) // Handle empty response body gracefully if len(bodyBytes) == 0 { return target, nil @@ -6512,7 +6557,7 @@ func (c *Client) CreatePerson(ctx context.Context, options *CreatePersonRequestO StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "CreatePersonResponseJSON", + TargetType: "CreatePersonRelationshipResponse", Body: bodyBytes, Err: err, } @@ -6520,18 +6565,18 @@ func (c *Client) CreatePerson(ctx context.Context, options *CreatePersonRequestO return target, nil } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/person-relationships") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } return responseParser(ctx, resp) } -// DeletePerson Delete a durable person profile -func (c *Client) DeletePerson(ctx context.Context, options *DeletePersonRequestOptions, reqEditors ...runtime.RequestEditorFn) (*struct{}, error) { +// DeletePersonRelationship Delete a person relationship +func (c *Client) DeletePersonRelationship(ctx context.Context, options *DeletePersonRelationshipRequestOptions, reqEditors ...runtime.RequestEditorFn) (*struct{}, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/person-relationships/{id}", Method: "DELETE", Options: options, } @@ -6544,7 +6589,7 @@ func (c *Client) DeletePerson(ctx context.Context, options *DeletePersonRequestO responseParser := func(ctx context.Context, resp *runtime.Response) (*struct{}, error) { bodyBytes := resp.Content if resp.StatusCode != 204 { - target := new(DeletePersonErrorResponse) + target := new(DeletePersonRelationshipErrorResponse) // Handle empty error response body gracefully - skip unmarshal if no content if len(bodyBytes) > 0 { if err = json.Unmarshal(bodyBytes, target); err != nil { @@ -6552,7 +6597,7 @@ func (c *Client) DeletePerson(ctx context.Context, options *DeletePersonRequestO StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "DeletePersonErrorResponse", + TargetType: "DeletePersonRelationshipErrorResponse", Body: bodyBytes, Err: err, } @@ -6568,18 +6613,18 @@ func (c *Client) DeletePerson(ctx context.Context, options *DeletePersonRequestO return nil, nil } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/person-relationships/{id}") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } return responseParser(ctx, resp) } -// GetPersonProfile Get a durable person profile -func (c *Client) GetPersonProfile(ctx context.Context, options *GetPersonProfileRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonProfileResponse, error) { +// GetPersonRelationship Get one person relationship +func (c *Client) GetPersonRelationship(ctx context.Context, options *GetPersonRelationshipRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonRelationshipResponse, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/person-relationships/{id}", Method: "GET", Options: options, } @@ -6589,10 +6634,10 @@ func (c *Client) GetPersonProfile(ctx context.Context, options *GetPersonProfile return nil, fmt.Errorf("error creating request: %w", err) } - responseParser := func(ctx context.Context, resp *runtime.Response) (*GetPersonProfileResponse, error) { + responseParser := func(ctx context.Context, resp *runtime.Response) (*GetPersonRelationshipResponse, error) { bodyBytes := resp.Content if resp.StatusCode != 200 { - target := new(GetPersonProfileErrorResponse) + target := new(GetPersonRelationshipErrorResponse) // Handle empty error response body gracefully - skip unmarshal if no content if len(bodyBytes) > 0 { if err = json.Unmarshal(bodyBytes, target); err != nil { @@ -6600,7 +6645,7 @@ func (c *Client) GetPersonProfile(ctx context.Context, options *GetPersonProfile StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetPersonProfileErrorResponse", + TargetType: "GetPersonRelationshipErrorResponse", Body: bodyBytes, Err: err, } @@ -6613,7 +6658,7 @@ func (c *Client) GetPersonProfile(ctx context.Context, options *GetPersonProfile return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), runtime.WithStatusCode(resp.StatusCode)) } - target := new(GetPersonProfileResponse) + target := new(GetPersonRelationshipResponse) // Handle empty response body gracefully if len(bodyBytes) == 0 { return target, nil @@ -6623,7 +6668,7 @@ func (c *Client) GetPersonProfile(ctx context.Context, options *GetPersonProfile StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetPersonProfileResponse", + TargetType: "GetPersonRelationshipResponse", Body: bodyBytes, Err: err, } @@ -6631,18 +6676,18 @@ func (c *Client) GetPersonProfile(ctx context.Context, options *GetPersonProfile return target, nil } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/person-relationships/{id}") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } return responseParser(ctx, resp) } -// PatchPerson Update a durable person's display name -func (c *Client) PatchPerson(ctx context.Context, options *PatchPersonRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PatchPersonResponse, error) { +// PatchPersonRelationship End a relationship or replace its notes +func (c *Client) PatchPersonRelationship(ctx context.Context, options *PatchPersonRelationshipRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PatchPersonRelationshipResponse, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/person-relationships/{id}", Method: "PATCH", Options: options, ContentType: "application/json", @@ -6653,10 +6698,10 @@ func (c *Client) PatchPerson(ctx context.Context, options *PatchPersonRequestOpt return nil, fmt.Errorf("error creating request: %w", err) } - responseParser := func(ctx context.Context, resp *runtime.Response) (*PatchPersonResponse, error) { + responseParser := func(ctx context.Context, resp *runtime.Response) (*PatchPersonRelationshipResponse, error) { bodyBytes := resp.Content if resp.StatusCode != 200 { - target := new(PatchPersonErrorResponse) + target := new(PatchPersonRelationshipErrorResponse) // Handle empty error response body gracefully - skip unmarshal if no content if len(bodyBytes) > 0 { if err = json.Unmarshal(bodyBytes, target); err != nil { @@ -6664,7 +6709,7 @@ func (c *Client) PatchPerson(ctx context.Context, options *PatchPersonRequestOpt StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "PatchPersonErrorResponse", + TargetType: "PatchPersonRelationshipErrorResponse", Body: bodyBytes, Err: err, } @@ -6677,7 +6722,7 @@ func (c *Client) PatchPerson(ctx context.Context, options *PatchPersonRequestOpt return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), runtime.WithStatusCode(resp.StatusCode)) } - target := new(PatchPersonResponse) + target := new(PatchPersonRelationshipResponse) // Handle empty response body gracefully if len(bodyBytes) == 0 { return target, nil @@ -6687,7 +6732,7 @@ func (c *Client) PatchPerson(ctx context.Context, options *PatchPersonRequestOpt StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "PatchPersonResponse", + TargetType: "PatchPersonRelationshipResponse", Body: bodyBytes, Err: err, } @@ -6695,20 +6740,19 @@ func (c *Client) PatchPerson(ctx context.Context, options *PatchPersonRequestOpt return target, nil } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/person-relationships/{id}") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } return responseParser(ctx, resp) } -// ListPersonAttributes List a person's typed attributes -func (c *Client) ListPersonAttributes(ctx context.Context, options *ListPersonAttributesRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonAttributesResponse, error) { +// ListPersons List durable person profiles +func (c *Client) ListPersons(ctx context.Context, reqEditors ...runtime.RequestEditorFn) (*ListPersonsResponse, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/attributes", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons", Method: "GET", - Options: options, } req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) @@ -6716,10 +6760,10 @@ func (c *Client) ListPersonAttributes(ctx context.Context, options *ListPersonAt return nil, fmt.Errorf("error creating request: %w", err) } - responseParser := func(ctx context.Context, resp *runtime.Response) (*ListPersonAttributesResponse, error) { + responseParser := func(ctx context.Context, resp *runtime.Response) (*ListPersonsResponse, error) { bodyBytes := resp.Content if resp.StatusCode != 200 { - target := new(ListPersonAttributesErrorResponse) + target := new(ListPersonsErrorResponse) // Handle empty error response body gracefully - skip unmarshal if no content if len(bodyBytes) > 0 { if err = json.Unmarshal(bodyBytes, target); err != nil { @@ -6727,7 +6771,7 @@ func (c *Client) ListPersonAttributes(ctx context.Context, options *ListPersonAt StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "ListPersonAttributesErrorResponse", + TargetType: "ListPersonsErrorResponse", Body: bodyBytes, Err: err, } @@ -6740,7 +6784,7 @@ func (c *Client) ListPersonAttributes(ctx context.Context, options *ListPersonAt return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), runtime.WithStatusCode(resp.StatusCode)) } - target := new(ListPersonAttributesResponse) + target := new(ListPersonsResponse) // Handle empty response body gracefully if len(bodyBytes) == 0 { return target, nil @@ -6750,7 +6794,7 @@ func (c *Client) ListPersonAttributes(ctx context.Context, options *ListPersonAt StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "ListPersonAttributesResponse", + TargetType: "ListPersonsResponse", Body: bodyBytes, Err: err, } @@ -6758,20 +6802,21 @@ func (c *Client) ListPersonAttributes(ctx context.Context, options *ListPersonAt return target, nil } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}/attributes") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } return responseParser(ctx, resp) } -// ClearPersonAttribute Supersede a person's attribute value -func (c *Client) ClearPersonAttribute(ctx context.Context, options *ClearPersonAttributeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ClearPersonAttributeResponse, error) { +// CreatePerson Promote a participant cluster to a durable person +func (c *Client) CreatePerson(ctx context.Context, options *CreatePersonRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CreatePersonResponseJSON, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/attributes/{slug}", - Method: "DELETE", - Options: options, + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons", + Method: "POST", + Options: options, + ContentType: "application/json", } req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) @@ -6779,10 +6824,10 @@ func (c *Client) ClearPersonAttribute(ctx context.Context, options *ClearPersonA return nil, fmt.Errorf("error creating request: %w", err) } - responseParser := func(ctx context.Context, resp *runtime.Response) (*ClearPersonAttributeResponse, error) { + responseParser := func(ctx context.Context, resp *runtime.Response) (*CreatePersonResponseJSON, error) { bodyBytes := resp.Content - if resp.StatusCode != 200 { - target := new(ClearPersonAttributeErrorResponse) + if resp.StatusCode != 201 { + target := new(CreatePersonErrorResponse) // Handle empty error response body gracefully - skip unmarshal if no content if len(bodyBytes) > 0 { if err = json.Unmarshal(bodyBytes, target); err != nil { @@ -6790,7 +6835,7 @@ func (c *Client) ClearPersonAttribute(ctx context.Context, options *ClearPersonA StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "ClearPersonAttributeErrorResponse", + TargetType: "CreatePersonErrorResponse", Body: bodyBytes, Err: err, } @@ -6803,7 +6848,7 @@ func (c *Client) ClearPersonAttribute(ctx context.Context, options *ClearPersonA return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), runtime.WithStatusCode(resp.StatusCode)) } - target := new(ClearPersonAttributeResponse) + target := new(CreatePersonResponseJSON) // Handle empty response body gracefully if len(bodyBytes) == 0 { return target, nil @@ -6813,7 +6858,7 @@ func (c *Client) ClearPersonAttribute(ctx context.Context, options *ClearPersonA StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "ClearPersonAttributeResponse", + TargetType: "CreatePersonResponseJSON", Body: bodyBytes, Err: err, } @@ -6821,21 +6866,20 @@ func (c *Client) ClearPersonAttribute(ctx context.Context, options *ClearPersonA return target, nil } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}/attributes/{slug}") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } return responseParser(ctx, resp) } -// SetPersonAttribute Set a person's attribute value -func (c *Client) SetPersonAttribute(ctx context.Context, options *SetPersonAttributeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SetPersonAttributeResponse, error) { +// DeletePerson Delete a durable person profile +func (c *Client) DeletePerson(ctx context.Context, options *DeletePersonRequestOptions, reqEditors ...runtime.RequestEditorFn) (*struct{}, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/attributes/{slug}", - Method: "PUT", - Options: options, - ContentType: "application/json", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}", + Method: "DELETE", + Options: options, } req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) @@ -6843,10 +6887,10 @@ func (c *Client) SetPersonAttribute(ctx context.Context, options *SetPersonAttri return nil, fmt.Errorf("error creating request: %w", err) } - responseParser := func(ctx context.Context, resp *runtime.Response) (*SetPersonAttributeResponse, error) { + responseParser := func(ctx context.Context, resp *runtime.Response) (*struct{}, error) { bodyBytes := resp.Content - if resp.StatusCode != 200 { - target := new(SetPersonAttributeErrorResponse) + if resp.StatusCode != 204 { + target := new(DeletePersonErrorResponse) // Handle empty error response body gracefully - skip unmarshal if no content if len(bodyBytes) > 0 { if err = json.Unmarshal(bodyBytes, target); err != nil { @@ -6854,7 +6898,7 @@ func (c *Client) SetPersonAttribute(ctx context.Context, options *SetPersonAttri StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "SetPersonAttributeErrorResponse", + TargetType: "DeletePersonErrorResponse", Body: bodyBytes, Err: err, } @@ -6867,36 +6911,21 @@ func (c *Client) SetPersonAttribute(ctx context.Context, options *SetPersonAttri return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), runtime.WithStatusCode(resp.StatusCode)) } - target := new(SetPersonAttributeResponse) - // 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: "SetPersonAttributeResponse", - Body: bodyBytes, - Err: err, - } - } - return target, nil + return nil, nil } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}/attributes/{slug}") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } 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) { +// GetPersonProfile Get a durable person profile +func (c *Client) GetPersonProfile(ctx context.Context, options *GetPersonProfileRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonProfileResponse, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/profile", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}", Method: "GET", Options: options, } @@ -6906,10 +6935,10 @@ func (c *Client) GetPersonStructuredProfile(ctx context.Context, options *GetPer return nil, fmt.Errorf("error creating request: %w", err) } - responseParser := func(ctx context.Context, resp *runtime.Response) (*GetPersonStructuredProfileResponse, error) { + responseParser := func(ctx context.Context, resp *runtime.Response) (*GetPersonProfileResponse, error) { bodyBytes := resp.Content if resp.StatusCode != 200 { - target := new(GetPersonStructuredProfileErrorResponse) + target := new(GetPersonProfileErrorResponse) // Handle empty error response body gracefully - skip unmarshal if no content if len(bodyBytes) > 0 { if err = json.Unmarshal(bodyBytes, target); err != nil { @@ -6917,7 +6946,7 @@ func (c *Client) GetPersonStructuredProfile(ctx context.Context, options *GetPer StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetPersonStructuredProfileErrorResponse", + TargetType: "GetPersonProfileErrorResponse", Body: bodyBytes, Err: err, } @@ -6930,7 +6959,7 @@ func (c *Client) GetPersonStructuredProfile(ctx context.Context, options *GetPer return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), runtime.WithStatusCode(resp.StatusCode)) } - target := new(GetPersonStructuredProfileResponse) + target := new(GetPersonProfileResponse) // Handle empty response body gracefully if len(bodyBytes) == 0 { return target, nil @@ -6940,7 +6969,7 @@ func (c *Client) GetPersonStructuredProfile(ctx context.Context, options *GetPer StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetPersonStructuredProfileResponse", + TargetType: "GetPersonProfileResponse", Body: bodyBytes, Err: err, } @@ -6948,18 +6977,18 @@ func (c *Client) GetPersonStructuredProfile(ctx context.Context, options *GetPer return target, nil } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}/profile") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}") 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) { +// PatchPerson Update a durable person's display name +func (c *Client) PatchPerson(ctx context.Context, options *PatchPersonRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PatchPersonResponse, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/profile", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}", Method: "PATCH", Options: options, ContentType: "application/json", @@ -6970,10 +6999,10 @@ func (c *Client) PatchPersonStructuredProfile(ctx context.Context, options *Patc return nil, fmt.Errorf("error creating request: %w", err) } - responseParser := func(ctx context.Context, resp *runtime.Response) (*PatchPersonStructuredProfileResponse, error) { + responseParser := func(ctx context.Context, resp *runtime.Response) (*PatchPersonResponse, error) { bodyBytes := resp.Content if resp.StatusCode != 200 { - target := new(PatchPersonStructuredProfileErrorResponse) + target := new(PatchPersonErrorResponse) // Handle empty error response body gracefully - skip unmarshal if no content if len(bodyBytes) > 0 { if err = json.Unmarshal(bodyBytes, target); err != nil { @@ -6981,7 +7010,7 @@ func (c *Client) PatchPersonStructuredProfile(ctx context.Context, options *Patc StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "PatchPersonStructuredProfileErrorResponse", + TargetType: "PatchPersonErrorResponse", Body: bodyBytes, Err: err, } @@ -6994,7 +7023,7 @@ func (c *Client) PatchPersonStructuredProfile(ctx context.Context, options *Patc return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), runtime.WithStatusCode(resp.StatusCode)) } - target := new(PatchPersonStructuredProfileResponse) + target := new(PatchPersonResponse) // Handle empty response body gracefully if len(bodyBytes) == 0 { return target, nil @@ -7004,7 +7033,7 @@ func (c *Client) PatchPersonStructuredProfile(ctx context.Context, options *Patc StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "PatchPersonStructuredProfileResponse", + TargetType: "PatchPersonResponse", Body: bodyBytes, Err: err, } @@ -7012,18 +7041,18 @@ func (c *Client) PatchPersonStructuredProfile(ctx context.Context, options *Patc return target, nil } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}/profile") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}") 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) { +// ListPersonAttributes List a person's typed attributes +func (c *Client) ListPersonAttributes(ctx context.Context, options *ListPersonAttributesRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonAttributesResponse, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/profile/history", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/attributes", Method: "GET", Options: options, } @@ -7033,10 +7062,10 @@ func (c *Client) GetPersonProfileHistory(ctx context.Context, options *GetPerson return nil, fmt.Errorf("error creating request: %w", err) } - responseParser := func(ctx context.Context, resp *runtime.Response) (*GetPersonProfileHistoryResponse, error) { + responseParser := func(ctx context.Context, resp *runtime.Response) (*ListPersonAttributesResponse, error) { bodyBytes := resp.Content if resp.StatusCode != 200 { - target := new(GetPersonProfileHistoryErrorResponse) + target := new(ListPersonAttributesErrorResponse) // Handle empty error response body gracefully - skip unmarshal if no content if len(bodyBytes) > 0 { if err = json.Unmarshal(bodyBytes, target); err != nil { @@ -7044,7 +7073,7 @@ func (c *Client) GetPersonProfileHistory(ctx context.Context, options *GetPerson StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetPersonProfileHistoryErrorResponse", + TargetType: "ListPersonAttributesErrorResponse", Body: bodyBytes, Err: err, } @@ -7057,7 +7086,7 @@ func (c *Client) GetPersonProfileHistory(ctx context.Context, options *GetPerson return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), runtime.WithStatusCode(resp.StatusCode)) } - target := new(GetPersonProfileHistoryResponse) + target := new(ListPersonAttributesResponse) // Handle empty response body gracefully if len(bodyBytes) == 0 { return target, nil @@ -7067,7 +7096,7 @@ func (c *Client) GetPersonProfileHistory(ctx context.Context, options *GetPerson StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetPersonProfileHistoryResponse", + TargetType: "ListPersonAttributesResponse", Body: bodyBytes, Err: err, } @@ -7075,19 +7104,19 @@ func (c *Client) GetPersonProfileHistory(ctx context.Context, options *GetPerson return target, nil } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}/profile/history") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}/attributes") 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) { +// ClearPersonAttribute Supersede a person's attribute value +func (c *Client) ClearPersonAttribute(ctx context.Context, options *ClearPersonAttributeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ClearPersonAttributeResponse, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/profile/media/{media_id}/content", - Method: "GET", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/attributes/{slug}", + Method: "DELETE", Options: options, } @@ -7096,10 +7125,10 @@ func (c *Client) GetPersonProfileMediaContent(ctx context.Context, options *GetP return nil, fmt.Errorf("error creating request: %w", err) } - responseParser := func(ctx context.Context, resp *runtime.Response) (*GetPersonProfileMediaContentResponse, error) { + responseParser := func(ctx context.Context, resp *runtime.Response) (*ClearPersonAttributeResponse, error) { bodyBytes := resp.Content if resp.StatusCode != 200 { - target := new(GetPersonProfileMediaContentErrorResponse) + target := new(ClearPersonAttributeErrorResponse) // Handle empty error response body gracefully - skip unmarshal if no content if len(bodyBytes) > 0 { if err = json.Unmarshal(bodyBytes, target); err != nil { @@ -7107,7 +7136,7 @@ func (c *Client) GetPersonProfileMediaContent(ctx context.Context, options *GetP StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetPersonProfileMediaContentErrorResponse", + TargetType: "ClearPersonAttributeErrorResponse", Body: bodyBytes, Err: err, } @@ -7120,23 +7149,37 @@ func (c *Client) GetPersonProfileMediaContent(ctx context.Context, options *GetP return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), runtime.WithStatusCode(resp.StatusCode)) } - result := GetPersonProfileMediaContentResponse(bodyBytes) - return &result, nil + target := new(ClearPersonAttributeResponse) + // 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: "ClearPersonAttributeResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}/profile/media/{media_id}/content") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}/attributes/{slug}") 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) { +// SetPersonAttribute Set a person's attribute value +func (c *Client) SetPersonAttribute(ctx context.Context, options *SetPersonAttributeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SetPersonAttributeResponse, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/query", - Method: "POST", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/attributes/{slug}", + Method: "PUT", Options: options, ContentType: "application/json", } @@ -7146,10 +7189,10 @@ func (c *Client) RunQuery(ctx context.Context, options *RunQueryRequestOptions, return nil, fmt.Errorf("error creating request: %w", err) } - responseParser := func(ctx context.Context, resp *runtime.Response) (*RunQueryResponse, error) { + responseParser := func(ctx context.Context, resp *runtime.Response) (*SetPersonAttributeResponse, error) { bodyBytes := resp.Content if resp.StatusCode != 200 { - target := new(RunQueryErrorResponse) + target := new(SetPersonAttributeErrorResponse) // Handle empty error response body gracefully - skip unmarshal if no content if len(bodyBytes) > 0 { if err = json.Unmarshal(bodyBytes, target); err != nil { @@ -7157,7 +7200,7 @@ func (c *Client) RunQuery(ctx context.Context, options *RunQueryRequestOptions, StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "RunQueryErrorResponse", + TargetType: "SetPersonAttributeErrorResponse", Body: bodyBytes, Err: err, } @@ -7170,7 +7213,7 @@ func (c *Client) RunQuery(ctx context.Context, options *RunQueryRequestOptions, return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), runtime.WithStatusCode(resp.StatusCode)) } - target := new(RunQueryResponse) + target := new(SetPersonAttributeResponse) // Handle empty response body gracefully if len(bodyBytes) == 0 { return target, nil @@ -7180,7 +7223,7 @@ func (c *Client) RunQuery(ctx context.Context, options *RunQueryRequestOptions, StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "RunQueryResponse", + TargetType: "SetPersonAttributeResponse", Body: bodyBytes, Err: err, } @@ -7188,7 +7231,674 @@ func (c *Client) RunQuery(ctx context.Context, options *RunQueryRequestOptions, return target, nil } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/query") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}/attributes/{slug}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + 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) +} + +// ListPersonRelationships List one person's relationships +func (c *Client) ListPersonRelationships(ctx context.Context, options *ListPersonRelationshipsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonRelationshipsResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/relationships", + 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) (*ListPersonRelationshipsResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(ListPersonRelationshipsErrorResponse) + // 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: "ListPersonRelationshipsErrorResponse", + 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(ListPersonRelationshipsResponse) + // 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: "ListPersonRelationshipsResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}/relationships") + 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 + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/query", + 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) (*RunQueryResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(RunQueryErrorResponse) + // 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: "RunQueryErrorResponse", + 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(RunQueryResponse) + // 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: "RunQueryResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/query") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// ListRelationshipTypes List person relationship types +func (c *Client) ListRelationshipTypes(ctx context.Context, reqEditors ...runtime.RequestEditorFn) (*ListRelationshipTypesResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/relationship-types", + Method: "GET", + } + + 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) (*ListRelationshipTypesResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(ListRelationshipTypesErrorResponse) + // 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: "ListRelationshipTypesErrorResponse", + 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(ListRelationshipTypesResponse) + // 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: "ListRelationshipTypesResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/relationship-types") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// CreateRelationshipType Create a user-owned relationship type +func (c *Client) CreateRelationshipType(ctx context.Context, options *CreateRelationshipTypeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CreateRelationshipTypeResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/relationship-types", + 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) (*CreateRelationshipTypeResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 201 { + target := new(CreateRelationshipTypeErrorResponse) + // 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: "CreateRelationshipTypeErrorResponse", + 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(CreateRelationshipTypeResponse) + // 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: "CreateRelationshipTypeResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/relationship-types") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// DeleteRelationshipType Delete an unused relationship type +func (c *Client) DeleteRelationshipType(ctx context.Context, options *DeleteRelationshipTypeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*struct{}, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/relationship-types/{id}", + Method: "DELETE", + 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) (*struct{}, error) { + bodyBytes := resp.Content + if resp.StatusCode != 204 { + target := new(DeleteRelationshipTypeErrorResponse) + // 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: "DeleteRelationshipTypeErrorResponse", + 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)) + } + return nil, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/relationship-types/{id}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// GetRelationshipType Get a relationship type +func (c *Client) GetRelationshipType(ctx context.Context, options *GetRelationshipTypeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetRelationshipTypeResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/relationship-types/{id}", + 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) (*GetRelationshipTypeResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(GetRelationshipTypeErrorResponse) + // 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: "GetRelationshipTypeErrorResponse", + 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(GetRelationshipTypeResponse) + // 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: "GetRelationshipTypeResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/relationship-types/{id}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// PatchRelationshipType Update a relationship type +func (c *Client) PatchRelationshipType(ctx context.Context, options *PatchRelationshipTypeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PatchRelationshipTypeResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/relationship-types/{id}", + 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) (*PatchRelationshipTypeResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(PatchRelationshipTypeErrorResponse) + // 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: "PatchRelationshipTypeErrorResponse", + 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(PatchRelationshipTypeResponse) + // 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: "PatchRelationshipTypeResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/relationship-types/{id}") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } diff --git a/pkg/client/generated/client_options.go b/pkg/client/generated/client_options.go index b84e06bb2..ed6c53ab6 100644 --- a/pkg/client/generated/client_options.go +++ b/pkg/client/generated/client_options.go @@ -3714,6 +3714,253 @@ func (o *GetPersonTimelineRequestOptions) GetHeader() (map[string]string, error) return nil, nil } +// ListPersonRelationshipReviewsRequestOptions is the options needed to make a request to ListPersonRelationshipReviews. +type ListPersonRelationshipReviewsRequestOptions struct { + Query *ListPersonRelationshipReviewsQuery +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *ListPersonRelationshipReviewsRequestOptions) 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 *ListPersonRelationshipReviewsRequestOptions) GetPathParams() (map[string]any, error) { + return nil, nil +} + +// GetQuery returns the query params as a map. +func (o *ListPersonRelationshipReviewsRequestOptions) 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 *ListPersonRelationshipReviewsRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *ListPersonRelationshipReviewsRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + +// CreatePersonRelationshipRequestOptions is the options needed to make a request to CreatePersonRelationship. +type CreatePersonRelationshipRequestOptions struct { + Body *CreatePersonRelationshipBody +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *CreatePersonRelationshipRequestOptions) 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 *CreatePersonRelationshipRequestOptions) GetPathParams() (map[string]any, error) { + return nil, nil +} + +// GetQuery returns the query params as a map. +func (o *CreatePersonRelationshipRequestOptions) 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 *CreatePersonRelationshipRequestOptions) GetBody() any { + return o.Body +} + +// GetHeader returns the headers as a map. +func (o *CreatePersonRelationshipRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + +// DeletePersonRelationshipRequestOptions is the options needed to make a request to DeletePersonRelationship. +type DeletePersonRelationshipRequestOptions struct { + PathParams *DeletePersonRelationshipPath + Header *DeletePersonRelationshipHeaders +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *DeletePersonRelationshipRequestOptions) 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.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 *DeletePersonRelationshipRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *DeletePersonRelationshipRequestOptions) 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 *DeletePersonRelationshipRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *DeletePersonRelationshipRequestOptions) GetHeader() (map[string]string, error) { + return runtime.AsMap[string](o.Header) +} + +// GetPersonRelationshipRequestOptions is the options needed to make a request to GetPersonRelationship. +type GetPersonRelationshipRequestOptions struct { + PathParams *GetPersonRelationshipPath +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *GetPersonRelationshipRequestOptions) 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 *GetPersonRelationshipRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *GetPersonRelationshipRequestOptions) 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 *GetPersonRelationshipRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *GetPersonRelationshipRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + +// PatchPersonRelationshipRequestOptions is the options needed to make a request to PatchPersonRelationship. +type PatchPersonRelationshipRequestOptions struct { + PathParams *PatchPersonRelationshipPath + Body *PatchPersonRelationshipBody + Header *PatchPersonRelationshipHeaders +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *PatchPersonRelationshipRequestOptions) 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 *PatchPersonRelationshipRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *PatchPersonRelationshipRequestOptions) 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 *PatchPersonRelationshipRequestOptions) GetBody() any { + return o.Body +} + +// GetHeader returns the headers as a map. +func (o *PatchPersonRelationshipRequestOptions) GetHeader() (map[string]string, error) { + return runtime.AsMap[string](o.Header) +} + // CreatePersonRequestOptions is the options needed to make a request to CreatePerson. type CreatePersonRequestOptions struct { Body *CreatePersonBody @@ -4279,6 +4526,59 @@ func (o *GetPersonProfileMediaContentRequestOptions) GetHeader() (map[string]str return nil, nil } +// ListPersonRelationshipsRequestOptions is the options needed to make a request to ListPersonRelationships. +type ListPersonRelationshipsRequestOptions struct { + PathParams *ListPersonRelationshipsPath + Query *ListPersonRelationshipsQuery +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *ListPersonRelationshipsRequestOptions) 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.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 *ListPersonRelationshipsRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *ListPersonRelationshipsRequestOptions) 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 *ListPersonRelationshipsRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *ListPersonRelationshipsRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + // RunQueryRequestOptions is the options needed to make a request to RunQuery. type RunQueryRequestOptions struct { Body *RunQueryBody @@ -4323,6 +4623,209 @@ func (o *RunQueryRequestOptions) GetHeader() (map[string]string, error) { return nil, nil } +// CreateRelationshipTypeRequestOptions is the options needed to make a request to CreateRelationshipType. +type CreateRelationshipTypeRequestOptions struct { + Body *CreateRelationshipTypeBody +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *CreateRelationshipTypeRequestOptions) 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 *CreateRelationshipTypeRequestOptions) GetPathParams() (map[string]any, error) { + return nil, nil +} + +// GetQuery returns the query params as a map. +func (o *CreateRelationshipTypeRequestOptions) 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 *CreateRelationshipTypeRequestOptions) GetBody() any { + return o.Body +} + +// GetHeader returns the headers as a map. +func (o *CreateRelationshipTypeRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + +// DeleteRelationshipTypeRequestOptions is the options needed to make a request to DeleteRelationshipType. +type DeleteRelationshipTypeRequestOptions struct { + PathParams *DeleteRelationshipTypePath + Header *DeleteRelationshipTypeHeaders +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *DeleteRelationshipTypeRequestOptions) 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.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 *DeleteRelationshipTypeRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *DeleteRelationshipTypeRequestOptions) 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 *DeleteRelationshipTypeRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *DeleteRelationshipTypeRequestOptions) GetHeader() (map[string]string, error) { + return runtime.AsMap[string](o.Header) +} + +// GetRelationshipTypeRequestOptions is the options needed to make a request to GetRelationshipType. +type GetRelationshipTypeRequestOptions struct { + PathParams *GetRelationshipTypePath +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *GetRelationshipTypeRequestOptions) 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 *GetRelationshipTypeRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *GetRelationshipTypeRequestOptions) 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 *GetRelationshipTypeRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *GetRelationshipTypeRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + +// PatchRelationshipTypeRequestOptions is the options needed to make a request to PatchRelationshipType. +type PatchRelationshipTypeRequestOptions struct { + PathParams *PatchRelationshipTypePath + Body *PatchRelationshipTypeBody + Header *PatchRelationshipTypeHeaders +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *PatchRelationshipTypeRequestOptions) 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 *PatchRelationshipTypeRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *PatchRelationshipTypeRequestOptions) 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 *PatchRelationshipTypeRequestOptions) GetBody() any { + return o.Body +} + +// GetHeader returns the headers as a map. +func (o *PatchRelationshipTypeRequestOptions) GetHeader() (map[string]string, error) { + return runtime.AsMap[string](o.Header) +} + // ListRelationshipsRequestOptions is the options needed to make a request to ListRelationships. type ListRelationshipsRequestOptions struct { Body *ListRelationshipsBody diff --git a/pkg/client/generated/client_with_response.go b/pkg/client/generated/client_with_response.go index 64e447b5a..470880f4a 100644 --- a/pkg/client/generated/client_with_response.go +++ b/pkg/client/generated/client_with_response.go @@ -6972,12 +6972,13 @@ func (c *Client) GetPersonTimelineWithResponse(ctx context.Context, options *Get } } -// ListPersons List durable person profiles -func (c *Client) ListPersonsWithResponse(ctx context.Context, reqEditors ...runtime.RequestEditorFn) (*ListPersonsResp, error) { +// ListPersonRelationshipReviews List imported RELATED values awaiting review +func (c *Client) ListPersonRelationshipReviewsWithResponse(ctx context.Context, options *ListPersonRelationshipReviewsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonRelationshipReviewsResp, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/person-relationship-reviews", Method: "GET", + Options: options, } req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) @@ -6985,12 +6986,12 @@ func (c *Client) ListPersonsWithResponse(ctx context.Context, reqEditors ...runt return nil, fmt.Errorf("error creating request: %w", err) } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/person-relationship-reviews") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } - out := &ListPersonsResp{ + out := &ListPersonRelationshipReviewsResp{ HTTPResponse: resp.Raw, Body: resp.Content, StatusCode: resp.StatusCode, @@ -6998,7 +6999,7 @@ func (c *Client) ListPersonsWithResponse(ctx context.Context, reqEditors ...runt switch resp.StatusCode { case 200: - out.JSON200 = new(ListPersonsResponse) + out.JSON200 = new(ListPersonRelationshipReviewsResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { @@ -7006,15 +7007,31 @@ func (c *Client) ListPersonsWithResponse(ctx context.Context, reqEditors ...runt StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "ListPersonsResponse", + TargetType: "ListPersonRelationshipReviewsResponse", Body: bodyBytes, Err: err, } } } return out, nil + case 400: + out.JSON400 = new(ListPersonRelationshipReviewsErrorResponse) + 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: "ListPersonRelationshipReviewsErrorResponse", + 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(ListPersonsErrorResponse) + out.JSON503 = new(ListPersonRelationshipReviewsErrorResponseJSON) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { @@ -7022,7 +7039,7 @@ func (c *Client) ListPersonsWithResponse(ctx context.Context, reqEditors ...runt StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "ListPersonsErrorResponse", + TargetType: "ListPersonRelationshipReviewsErrorResponseJSON", Body: bodyBytes, Err: err, } @@ -7034,11 +7051,11 @@ func (c *Client) ListPersonsWithResponse(ctx context.Context, reqEditors ...runt } } -// CreatePerson Promote a participant cluster to a durable person -func (c *Client) CreatePersonWithResponse(ctx context.Context, options *CreatePersonRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CreatePersonResp, error) { +// CreatePersonRelationship Declare a relationship between two persons +func (c *Client) CreatePersonRelationshipWithResponse(ctx context.Context, options *CreatePersonRelationshipRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CreatePersonRelationshipResp, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/person-relationships", Method: "POST", Options: options, ContentType: "application/json", @@ -7049,58 +7066,72 @@ func (c *Client) CreatePersonWithResponse(ctx context.Context, options *CreatePe return nil, fmt.Errorf("error creating request: %w", err) } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/person-relationships") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } - out := &CreatePersonResp{ + out := &CreatePersonRelationshipResp{ HTTPResponse: resp.Raw, Body: resp.Content, StatusCode: resp.StatusCode, } switch resp.StatusCode { - case 200: - out.JSON200 = new(CreatePersonResponse) + case 201: + out.JSON201 = new(CreatePersonRelationshipResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { - if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + if err := json.Unmarshal(bodyBytes, out.JSON201); err != nil { return out, &runtime.ResponseDecodeError{ StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "CreatePersonResponse", + TargetType: "CreatePersonRelationshipResponse", Body: bodyBytes, Err: err, } } } - out.Headers200 = &CreatePersonResp200Headers{ - ETag: resp.Headers.Get("ETag"), + out.Headers201 = &CreatePersonRelationshipResp201Headers{ + ETag: resp.Headers.Get("ETag"), + Location: resp.Headers.Get("Location"), } return out, nil - case 201: - out.JSON201 = new(CreatePersonResponseJSON) + case 400: + out.JSON400 = new(CreatePersonRelationshipErrorResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { - if err := json.Unmarshal(bodyBytes, out.JSON201); err != nil { + 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: "CreatePersonResponseJSON", + TargetType: "CreatePersonRelationshipErrorResponse", Body: bodyBytes, Err: err, } } } - out.Headers201 = &CreatePersonResp201Headers{ - ETag: resp.Headers.Get("ETag"), + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(CreatePersonRelationshipErrorResponseJSON) + 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: "CreatePersonRelationshipErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } } - return out, nil + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 409: - out.JSON409 = new(CreatePersonErrorResponse) + out.JSON409 = new(CreatePersonRelationshipErrorResponseJSON409) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { @@ -7108,7 +7139,7 @@ func (c *Client) CreatePersonWithResponse(ctx context.Context, options *CreatePe StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "CreatePersonErrorResponse", + TargetType: "CreatePersonRelationshipErrorResponseJSON409", Body: bodyBytes, Err: err, } @@ -7116,7 +7147,7 @@ func (c *Client) CreatePersonWithResponse(ctx context.Context, options *CreatePe } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 503: - out.JSON503 = new(CreatePersonErrorResponseJSON) + out.JSON503 = new(CreatePersonRelationshipErrorResponseJSON503) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { @@ -7124,7 +7155,7 @@ func (c *Client) CreatePersonWithResponse(ctx context.Context, options *CreatePe StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "CreatePersonErrorResponseJSON", + TargetType: "CreatePersonRelationshipErrorResponseJSON503", Body: bodyBytes, Err: err, } @@ -7136,11 +7167,11 @@ func (c *Client) CreatePersonWithResponse(ctx context.Context, options *CreatePe } } -// DeletePerson Delete a durable person profile -func (c *Client) DeletePersonWithResponse(ctx context.Context, options *DeletePersonRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DeletePersonResp, error) { +// DeletePersonRelationship Delete a person relationship +func (c *Client) DeletePersonRelationshipWithResponse(ctx context.Context, options *DeletePersonRelationshipRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DeletePersonRelationshipResp, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/person-relationships/{id}", Method: "DELETE", Options: options, } @@ -7150,12 +7181,12 @@ func (c *Client) DeletePersonWithResponse(ctx context.Context, options *DeletePe return nil, fmt.Errorf("error creating request: %w", err) } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/person-relationships/{id}") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } - out := &DeletePersonResp{ + out := &DeletePersonRelationshipResp{ HTTPResponse: resp.Raw, Body: resp.Content, StatusCode: resp.StatusCode, @@ -7165,7 +7196,7 @@ func (c *Client) DeletePersonWithResponse(ctx context.Context, options *DeletePe case 204: return out, nil case 400: - out.JSON400 = new(DeletePersonErrorResponse) + out.JSON400 = new(DeletePersonRelationshipErrorResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { @@ -7173,23 +7204,7 @@ func (c *Client) DeletePersonWithResponse(ctx context.Context, options *DeletePe StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "DeletePersonErrorResponse", - 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(DeletePersonErrorResponseJSON) - 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: "DeletePersonErrorResponseJSON", + TargetType: "DeletePersonRelationshipErrorResponse", Body: bodyBytes, Err: err, } @@ -7197,7 +7212,7 @@ func (c *Client) DeletePersonWithResponse(ctx context.Context, options *DeletePe } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 404: - out.JSON404 = new(DeletePersonErrorResponseJSON404) + out.JSON404 = new(DeletePersonRelationshipErrorResponseJSON) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { @@ -7205,7 +7220,7 @@ func (c *Client) DeletePersonWithResponse(ctx context.Context, options *DeletePe StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "DeletePersonErrorResponseJSON404", + TargetType: "DeletePersonRelationshipErrorResponseJSON", Body: bodyBytes, Err: err, } @@ -7213,7 +7228,7 @@ func (c *Client) DeletePersonWithResponse(ctx context.Context, options *DeletePe } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 409: - out.JSON409 = new(DeletePersonErrorResponseJSON409) + out.JSON409 = new(DeletePersonRelationshipErrorResponseJSON409) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { @@ -7221,7 +7236,7 @@ func (c *Client) DeletePersonWithResponse(ctx context.Context, options *DeletePe StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "DeletePersonErrorResponseJSON409", + TargetType: "DeletePersonRelationshipErrorResponseJSON409", Body: bodyBytes, Err: err, } @@ -7229,7 +7244,7 @@ func (c *Client) DeletePersonWithResponse(ctx context.Context, options *DeletePe } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 428: - out.JSON428 = new(DeletePersonErrorResponseJSON428) + out.JSON428 = new(DeletePersonRelationshipErrorResponseJSON428) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON428); err != nil { @@ -7237,23 +7252,7 @@ func (c *Client) DeletePersonWithResponse(ctx context.Context, options *DeletePe StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "DeletePersonErrorResponseJSON428", - 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(DeletePersonErrorResponseJSON500) - 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: "DeletePersonErrorResponseJSON500", + TargetType: "DeletePersonRelationshipErrorResponseJSON428", Body: bodyBytes, Err: err, } @@ -7261,7 +7260,7 @@ func (c *Client) DeletePersonWithResponse(ctx context.Context, options *DeletePe } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 503: - out.JSON503 = new(DeletePersonErrorResponseJSON503) + out.JSON503 = new(DeletePersonRelationshipErrorResponseJSON503) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { @@ -7269,7 +7268,7 @@ func (c *Client) DeletePersonWithResponse(ctx context.Context, options *DeletePe StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "DeletePersonErrorResponseJSON503", + TargetType: "DeletePersonRelationshipErrorResponseJSON503", Body: bodyBytes, Err: err, } @@ -7281,11 +7280,11 @@ func (c *Client) DeletePersonWithResponse(ctx context.Context, options *DeletePe } } -// GetPersonProfile Get a durable person profile -func (c *Client) GetPersonProfileWithResponse(ctx context.Context, options *GetPersonProfileRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonProfileResp, error) { +// GetPersonRelationship Get one person relationship +func (c *Client) GetPersonRelationshipWithResponse(ctx context.Context, options *GetPersonRelationshipRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonRelationshipResp, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/person-relationships/{id}", Method: "GET", Options: options, } @@ -7295,12 +7294,12 @@ func (c *Client) GetPersonProfileWithResponse(ctx context.Context, options *GetP return nil, fmt.Errorf("error creating request: %w", err) } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/person-relationships/{id}") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } - out := &GetPersonProfileResp{ + out := &GetPersonRelationshipResp{ HTTPResponse: resp.Raw, Body: resp.Content, StatusCode: resp.StatusCode, @@ -7308,7 +7307,7 @@ func (c *Client) GetPersonProfileWithResponse(ctx context.Context, options *GetP switch resp.StatusCode { case 200: - out.JSON200 = new(GetPersonProfileResponse) + out.JSON200 = new(GetPersonRelationshipResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { @@ -7316,18 +7315,18 @@ func (c *Client) GetPersonProfileWithResponse(ctx context.Context, options *GetP StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetPersonProfileResponse", + TargetType: "GetPersonRelationshipResponse", Body: bodyBytes, Err: err, } } } - out.Headers200 = &GetPersonProfileResp200Headers{ + out.Headers200 = &GetPersonRelationshipResp200Headers{ ETag: resp.Headers.Get("ETag"), } return out, nil case 404: - out.JSON404 = new(GetPersonProfileErrorResponse) + out.JSON404 = new(GetPersonRelationshipErrorResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { @@ -7335,7 +7334,7 @@ func (c *Client) GetPersonProfileWithResponse(ctx context.Context, options *GetP StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetPersonProfileErrorResponse", + TargetType: "GetPersonRelationshipErrorResponse", Body: bodyBytes, Err: err, } @@ -7343,7 +7342,7 @@ func (c *Client) GetPersonProfileWithResponse(ctx context.Context, options *GetP } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 503: - out.JSON503 = new(GetPersonProfileErrorResponseJSON) + out.JSON503 = new(GetPersonRelationshipErrorResponseJSON) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { @@ -7351,7 +7350,7 @@ func (c *Client) GetPersonProfileWithResponse(ctx context.Context, options *GetP StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetPersonProfileErrorResponseJSON", + TargetType: "GetPersonRelationshipErrorResponseJSON", Body: bodyBytes, Err: err, } @@ -7363,11 +7362,11 @@ func (c *Client) GetPersonProfileWithResponse(ctx context.Context, options *GetP } } -// PatchPerson Update a durable person's display name -func (c *Client) PatchPersonWithResponse(ctx context.Context, options *PatchPersonRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PatchPersonResp, error) { +// PatchPersonRelationship End a relationship or replace its notes +func (c *Client) PatchPersonRelationshipWithResponse(ctx context.Context, options *PatchPersonRelationshipRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PatchPersonRelationshipResp, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/person-relationships/{id}", Method: "PATCH", Options: options, ContentType: "application/json", @@ -7378,12 +7377,12 @@ func (c *Client) PatchPersonWithResponse(ctx context.Context, options *PatchPers return nil, fmt.Errorf("error creating request: %w", err) } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/person-relationships/{id}") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } - out := &PatchPersonResp{ + out := &PatchPersonRelationshipResp{ HTTPResponse: resp.Raw, Body: resp.Content, StatusCode: resp.StatusCode, @@ -7391,7 +7390,7 @@ func (c *Client) PatchPersonWithResponse(ctx context.Context, options *PatchPers switch resp.StatusCode { case 200: - out.JSON200 = new(PatchPersonResponse) + out.JSON200 = new(PatchPersonRelationshipResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { @@ -7399,18 +7398,34 @@ func (c *Client) PatchPersonWithResponse(ctx context.Context, options *PatchPers StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "PatchPersonResponse", + TargetType: "PatchPersonRelationshipResponse", Body: bodyBytes, Err: err, } } } - out.Headers200 = &PatchPersonResp200Headers{ + out.Headers200 = &PatchPersonRelationshipResp200Headers{ ETag: resp.Headers.Get("ETag"), } return out, nil + case 400: + out.JSON400 = new(PatchPersonRelationshipErrorResponse) + 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: "PatchPersonRelationshipErrorResponse", + 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(PatchPersonErrorResponse) + out.JSON404 = new(PatchPersonRelationshipErrorResponseJSON) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { @@ -7418,7 +7433,7 @@ func (c *Client) PatchPersonWithResponse(ctx context.Context, options *PatchPers StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "PatchPersonErrorResponse", + TargetType: "PatchPersonRelationshipErrorResponseJSON", Body: bodyBytes, Err: err, } @@ -7426,7 +7441,7 @@ func (c *Client) PatchPersonWithResponse(ctx context.Context, options *PatchPers } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 409: - out.JSON409 = new(PatchPersonErrorResponseJSON) + out.JSON409 = new(PatchPersonRelationshipErrorResponseJSON409) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { @@ -7434,7 +7449,7 @@ func (c *Client) PatchPersonWithResponse(ctx context.Context, options *PatchPers StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "PatchPersonErrorResponseJSON", + TargetType: "PatchPersonRelationshipErrorResponseJSON409", Body: bodyBytes, Err: err, } @@ -7442,7 +7457,7 @@ func (c *Client) PatchPersonWithResponse(ctx context.Context, options *PatchPers } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 428: - out.JSON428 = new(PatchPersonErrorResponseJSON428) + out.JSON428 = new(PatchPersonRelationshipErrorResponseJSON428) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON428); err != nil { @@ -7450,7 +7465,7 @@ func (c *Client) PatchPersonWithResponse(ctx context.Context, options *PatchPers StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "PatchPersonErrorResponseJSON428", + TargetType: "PatchPersonRelationshipErrorResponseJSON428", Body: bodyBytes, Err: err, } @@ -7458,7 +7473,7 @@ func (c *Client) PatchPersonWithResponse(ctx context.Context, options *PatchPers } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 503: - out.JSON503 = new(PatchPersonErrorResponseJSON503) + out.JSON503 = new(PatchPersonRelationshipErrorResponseJSON503) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { @@ -7466,7 +7481,7 @@ func (c *Client) PatchPersonWithResponse(ctx context.Context, options *PatchPers StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "PatchPersonErrorResponseJSON503", + TargetType: "PatchPersonRelationshipErrorResponseJSON503", Body: bodyBytes, Err: err, } @@ -7478,13 +7493,12 @@ func (c *Client) PatchPersonWithResponse(ctx context.Context, options *PatchPers } } -// ListPersonAttributes List a person's typed attributes -func (c *Client) ListPersonAttributesWithResponse(ctx context.Context, options *ListPersonAttributesRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonAttributesResp, error) { +// ListPersons List durable person profiles +func (c *Client) ListPersonsWithResponse(ctx context.Context, reqEditors ...runtime.RequestEditorFn) (*ListPersonsResp, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/attributes", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons", Method: "GET", - Options: options, } req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) @@ -7492,12 +7506,12 @@ func (c *Client) ListPersonAttributesWithResponse(ctx context.Context, options * return nil, fmt.Errorf("error creating request: %w", err) } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}/attributes") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } - out := &ListPersonAttributesResp{ + out := &ListPersonsResp{ HTTPResponse: resp.Raw, Body: resp.Content, StatusCode: resp.StatusCode, @@ -7505,7 +7519,7 @@ func (c *Client) ListPersonAttributesWithResponse(ctx context.Context, options * switch resp.StatusCode { case 200: - out.JSON200 = new(ListPersonAttributesResponse) + out.JSON200 = new(ListPersonsResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { @@ -7513,31 +7527,15 @@ func (c *Client) ListPersonAttributesWithResponse(ctx context.Context, options * StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "ListPersonAttributesResponse", + TargetType: "ListPersonsResponse", Body: bodyBytes, Err: err, } } } return out, nil - case 404: - out.JSON404 = new(ListPersonAttributesErrorResponse) - 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: "ListPersonAttributesErrorResponse", - 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(ListPersonAttributesErrorResponseJSON) + out.JSON503 = new(ListPersonsErrorResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { @@ -7545,7 +7543,7 @@ func (c *Client) ListPersonAttributesWithResponse(ctx context.Context, options * StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "ListPersonAttributesErrorResponseJSON", + TargetType: "ListPersonsErrorResponse", Body: bodyBytes, Err: err, } @@ -7557,13 +7555,14 @@ func (c *Client) ListPersonAttributesWithResponse(ctx context.Context, options * } } -// ClearPersonAttribute Supersede a person's attribute value -func (c *Client) ClearPersonAttributeWithResponse(ctx context.Context, options *ClearPersonAttributeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ClearPersonAttributeResp, error) { +// CreatePerson Promote a participant cluster to a durable person +func (c *Client) CreatePersonWithResponse(ctx context.Context, options *CreatePersonRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CreatePersonResp, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/attributes/{slug}", - Method: "DELETE", - Options: options, + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons", + Method: "POST", + Options: options, + ContentType: "application/json", } req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) @@ -7571,12 +7570,12 @@ func (c *Client) ClearPersonAttributeWithResponse(ctx context.Context, options * return nil, fmt.Errorf("error creating request: %w", err) } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}/attributes/{slug}") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } - out := &ClearPersonAttributeResp{ + out := &CreatePersonResp{ HTTPResponse: resp.Raw, Body: resp.Content, StatusCode: resp.StatusCode, @@ -7584,7 +7583,7 @@ func (c *Client) ClearPersonAttributeWithResponse(ctx context.Context, options * switch resp.StatusCode { case 200: - out.JSON200 = new(ClearPersonAttributeResponse) + out.JSON200 = new(CreatePersonResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { @@ -7592,47 +7591,37 @@ func (c *Client) ClearPersonAttributeWithResponse(ctx context.Context, options * StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "ClearPersonAttributeResponse", + TargetType: "CreatePersonResponse", Body: bodyBytes, Err: err, } } } + out.Headers200 = &CreatePersonResp200Headers{ + ETag: resp.Headers.Get("ETag"), + } return out, nil - case 400: - out.JSON400 = new(ClearPersonAttributeErrorResponse) + case 201: + out.JSON201 = new(CreatePersonResponseJSON) bodyBytes := resp.Content if len(bodyBytes) > 0 { - if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + if err := json.Unmarshal(bodyBytes, out.JSON201); err != nil { return out, &runtime.ResponseDecodeError{ StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "ClearPersonAttributeErrorResponse", + TargetType: "CreatePersonResponseJSON", 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(ClearPersonAttributeErrorResponseJSON) - 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: "ClearPersonAttributeErrorResponseJSON", - Body: bodyBytes, - Err: err, - } - } + out.Headers201 = &CreatePersonResp201Headers{ + ETag: resp.Headers.Get("ETag"), } - return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + return out, nil case 409: - out.JSON409 = new(ClearPersonAttributeErrorResponseJSON409) + out.JSON409 = new(CreatePersonErrorResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { @@ -7640,7 +7629,7 @@ func (c *Client) ClearPersonAttributeWithResponse(ctx context.Context, options * StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "ClearPersonAttributeErrorResponseJSON409", + TargetType: "CreatePersonErrorResponse", Body: bodyBytes, Err: err, } @@ -7648,7 +7637,7 @@ func (c *Client) ClearPersonAttributeWithResponse(ctx context.Context, options * } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 503: - out.JSON503 = new(ClearPersonAttributeErrorResponseJSON503) + out.JSON503 = new(CreatePersonErrorResponseJSON) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { @@ -7656,7 +7645,7 @@ func (c *Client) ClearPersonAttributeWithResponse(ctx context.Context, options * StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "ClearPersonAttributeErrorResponseJSON503", + TargetType: "CreatePersonErrorResponseJSON", Body: bodyBytes, Err: err, } @@ -7668,14 +7657,13 @@ func (c *Client) ClearPersonAttributeWithResponse(ctx context.Context, options * } } -// SetPersonAttribute Set a person's attribute value -func (c *Client) SetPersonAttributeWithResponse(ctx context.Context, options *SetPersonAttributeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SetPersonAttributeResp, error) { +// DeletePerson Delete a durable person profile +func (c *Client) DeletePersonWithResponse(ctx context.Context, options *DeletePersonRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DeletePersonResp, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/attributes/{slug}", - Method: "PUT", - Options: options, - ContentType: "application/json", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}", + Method: "DELETE", + Options: options, } req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) @@ -7683,44 +7671,46 @@ func (c *Client) SetPersonAttributeWithResponse(ctx context.Context, options *Se return nil, fmt.Errorf("error creating request: %w", err) } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}/attributes/{slug}") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } - out := &SetPersonAttributeResp{ + out := &DeletePersonResp{ HTTPResponse: resp.Raw, Body: resp.Content, StatusCode: resp.StatusCode, } switch resp.StatusCode { - case 200: - out.JSON200 = new(SetPersonAttributeResponse) + case 204: + return out, nil + case 400: + out.JSON400 = new(DeletePersonErrorResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { - if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + 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: "SetPersonAttributeResponse", + TargetType: "DeletePersonErrorResponse", Body: bodyBytes, Err: err, } } } - return out, nil - case 400: - out.JSON400 = new(SetPersonAttributeErrorResponse) + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 401: + out.JSON401 = new(DeletePersonErrorResponseJSON) bodyBytes := resp.Content if len(bodyBytes) > 0 { - if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + 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: "SetPersonAttributeErrorResponse", + TargetType: "DeletePersonErrorResponseJSON", Body: bodyBytes, Err: err, } @@ -7728,7 +7718,7 @@ func (c *Client) SetPersonAttributeWithResponse(ctx context.Context, options *Se } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 404: - out.JSON404 = new(SetPersonAttributeErrorResponseJSON) + out.JSON404 = new(DeletePersonErrorResponseJSON404) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { @@ -7736,7 +7726,7 @@ func (c *Client) SetPersonAttributeWithResponse(ctx context.Context, options *Se StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "SetPersonAttributeErrorResponseJSON", + TargetType: "DeletePersonErrorResponseJSON404", Body: bodyBytes, Err: err, } @@ -7744,7 +7734,7 @@ func (c *Client) SetPersonAttributeWithResponse(ctx context.Context, options *Se } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 409: - out.JSON409 = new(SetPersonAttributeErrorResponseJSON409) + out.JSON409 = new(DeletePersonErrorResponseJSON409) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { @@ -7752,7 +7742,39 @@ func (c *Client) SetPersonAttributeWithResponse(ctx context.Context, options *Se StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "SetPersonAttributeErrorResponseJSON409", + TargetType: "DeletePersonErrorResponseJSON409", + 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(DeletePersonErrorResponseJSON428) + 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: "DeletePersonErrorResponseJSON428", + 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(DeletePersonErrorResponseJSON500) + 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: "DeletePersonErrorResponseJSON500", Body: bodyBytes, Err: err, } @@ -7760,7 +7782,7 @@ func (c *Client) SetPersonAttributeWithResponse(ctx context.Context, options *Se } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 503: - out.JSON503 = new(SetPersonAttributeErrorResponseJSON503) + out.JSON503 = new(DeletePersonErrorResponseJSON503) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { @@ -7768,7 +7790,7 @@ func (c *Client) SetPersonAttributeWithResponse(ctx context.Context, options *Se StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "SetPersonAttributeErrorResponseJSON503", + TargetType: "DeletePersonErrorResponseJSON503", Body: bodyBytes, Err: err, } @@ -7780,11 +7802,11 @@ 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) { +// GetPersonProfile Get a durable person profile +func (c *Client) GetPersonProfileWithResponse(ctx context.Context, options *GetPersonProfileRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonProfileResp, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/profile", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}", Method: "GET", Options: options, } @@ -7794,12 +7816,12 @@ func (c *Client) GetPersonStructuredProfileWithResponse(ctx context.Context, opt return nil, fmt.Errorf("error creating request: %w", err) } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}/profile") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } - out := &GetPersonStructuredProfileResp{ + out := &GetPersonProfileResp{ HTTPResponse: resp.Raw, Body: resp.Content, StatusCode: resp.StatusCode, @@ -7807,7 +7829,7 @@ func (c *Client) GetPersonStructuredProfileWithResponse(ctx context.Context, opt switch resp.StatusCode { case 200: - out.JSON200 = new(GetPersonStructuredProfileResponse) + out.JSON200 = new(GetPersonProfileResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { @@ -7815,34 +7837,18 @@ func (c *Client) GetPersonStructuredProfileWithResponse(ctx context.Context, opt StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetPersonStructuredProfileResponse", + TargetType: "GetPersonProfileResponse", Body: bodyBytes, Err: err, } } } - out.Headers200 = &GetPersonStructuredProfileResp200Headers{ + out.Headers200 = &GetPersonProfileResp200Headers{ 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) + out.JSON404 = new(GetPersonProfileErrorResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { @@ -7850,7 +7856,7 @@ func (c *Client) GetPersonStructuredProfileWithResponse(ctx context.Context, opt StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetPersonStructuredProfileErrorResponseJSON", + TargetType: "GetPersonProfileErrorResponse", Body: bodyBytes, Err: err, } @@ -7858,7 +7864,7 @@ func (c *Client) GetPersonStructuredProfileWithResponse(ctx context.Context, opt } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 503: - out.JSON503 = new(GetPersonStructuredProfileErrorResponseJSON503) + out.JSON503 = new(GetPersonProfileErrorResponseJSON) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { @@ -7866,7 +7872,7 @@ func (c *Client) GetPersonStructuredProfileWithResponse(ctx context.Context, opt StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetPersonStructuredProfileErrorResponseJSON503", + TargetType: "GetPersonProfileErrorResponseJSON", Body: bodyBytes, Err: err, } @@ -7878,11 +7884,11 @@ func (c *Client) GetPersonStructuredProfileWithResponse(ctx context.Context, opt } } -// PatchPersonStructuredProfile Atomically patch a person's structured profile -func (c *Client) PatchPersonStructuredProfileWithResponse(ctx context.Context, options *PatchPersonStructuredProfileRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PatchPersonStructuredProfileResp, error) { +// PatchPerson Update a durable person's display name +func (c *Client) PatchPersonWithResponse(ctx context.Context, options *PatchPersonRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PatchPersonResp, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/profile", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}", Method: "PATCH", Options: options, ContentType: "application/json", @@ -7893,12 +7899,12 @@ func (c *Client) PatchPersonStructuredProfileWithResponse(ctx context.Context, o return nil, fmt.Errorf("error creating request: %w", err) } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}/profile") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } - out := &PatchPersonStructuredProfileResp{ + out := &PatchPersonResp{ HTTPResponse: resp.Raw, Body: resp.Content, StatusCode: resp.StatusCode, @@ -7906,7 +7912,7 @@ func (c *Client) PatchPersonStructuredProfileWithResponse(ctx context.Context, o switch resp.StatusCode { case 200: - out.JSON200 = new(PatchPersonStructuredProfileResponse) + out.JSON200 = new(PatchPersonResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { @@ -7914,34 +7920,18 @@ func (c *Client) PatchPersonStructuredProfileWithResponse(ctx context.Context, o StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "PatchPersonStructuredProfileResponse", + TargetType: "PatchPersonResponse", Body: bodyBytes, Err: err, } } } - out.Headers200 = &PatchPersonStructuredProfileResp200Headers{ + out.Headers200 = &PatchPersonResp200Headers{ 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) + out.JSON404 = new(PatchPersonErrorResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { @@ -7949,7 +7939,7 @@ func (c *Client) PatchPersonStructuredProfileWithResponse(ctx context.Context, o StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "PatchPersonStructuredProfileErrorResponseJSON", + TargetType: "PatchPersonErrorResponse", Body: bodyBytes, Err: err, } @@ -7957,7 +7947,7 @@ func (c *Client) PatchPersonStructuredProfileWithResponse(ctx context.Context, o } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 409: - out.JSON409 = new(PatchPersonStructuredProfileErrorResponseJSON409) + out.JSON409 = new(PatchPersonErrorResponseJSON) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { @@ -7965,23 +7955,7 @@ func (c *Client) PatchPersonStructuredProfileWithResponse(ctx context.Context, o 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", + TargetType: "PatchPersonErrorResponseJSON", Body: bodyBytes, Err: err, } @@ -7989,7 +7963,7 @@ func (c *Client) PatchPersonStructuredProfileWithResponse(ctx context.Context, o } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 428: - out.JSON428 = new(PatchPersonStructuredProfileErrorResponseJSON428) + out.JSON428 = new(PatchPersonErrorResponseJSON428) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON428); err != nil { @@ -7997,7 +7971,7 @@ func (c *Client) PatchPersonStructuredProfileWithResponse(ctx context.Context, o StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "PatchPersonStructuredProfileErrorResponseJSON428", + TargetType: "PatchPersonErrorResponseJSON428", Body: bodyBytes, Err: err, } @@ -8005,7 +7979,7 @@ func (c *Client) PatchPersonStructuredProfileWithResponse(ctx context.Context, o } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 503: - out.JSON503 = new(PatchPersonStructuredProfileErrorResponseJSON503) + out.JSON503 = new(PatchPersonErrorResponseJSON503) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { @@ -8013,7 +7987,7 @@ func (c *Client) PatchPersonStructuredProfileWithResponse(ctx context.Context, o StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "PatchPersonStructuredProfileErrorResponseJSON503", + TargetType: "PatchPersonErrorResponseJSON503", Body: bodyBytes, Err: err, } @@ -8025,11 +7999,11 @@ func (c *Client) PatchPersonStructuredProfileWithResponse(ctx context.Context, o } } -// GetPersonProfileHistory Get a person's structured profile history -func (c *Client) GetPersonProfileHistoryWithResponse(ctx context.Context, options *GetPersonProfileHistoryRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonProfileHistoryResp, error) { +// ListPersonAttributes List a person's typed attributes +func (c *Client) ListPersonAttributesWithResponse(ctx context.Context, options *ListPersonAttributesRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonAttributesResp, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/profile/history", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/attributes", Method: "GET", Options: options, } @@ -8039,12 +8013,12 @@ func (c *Client) GetPersonProfileHistoryWithResponse(ctx context.Context, option return nil, fmt.Errorf("error creating request: %w", err) } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}/profile/history") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}/attributes") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } - out := &GetPersonProfileHistoryResp{ + out := &ListPersonAttributesResp{ HTTPResponse: resp.Raw, Body: resp.Content, StatusCode: resp.StatusCode, @@ -8052,7 +8026,7 @@ func (c *Client) GetPersonProfileHistoryWithResponse(ctx context.Context, option switch resp.StatusCode { case 200: - out.JSON200 = new(GetPersonProfileHistoryResponse) + out.JSON200 = new(ListPersonAttributesResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { @@ -8060,31 +8034,15 @@ func (c *Client) GetPersonProfileHistoryWithResponse(ctx context.Context, option StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetPersonProfileHistoryResponse", + TargetType: "ListPersonAttributesResponse", 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) + out.JSON404 = new(ListPersonAttributesErrorResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { @@ -8092,7 +8050,7 @@ func (c *Client) GetPersonProfileHistoryWithResponse(ctx context.Context, option StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetPersonProfileHistoryErrorResponseJSON", + TargetType: "ListPersonAttributesErrorResponse", Body: bodyBytes, Err: err, } @@ -8100,7 +8058,7 @@ func (c *Client) GetPersonProfileHistoryWithResponse(ctx context.Context, option } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 503: - out.JSON503 = new(GetPersonProfileHistoryErrorResponseJSON503) + out.JSON503 = new(ListPersonAttributesErrorResponseJSON) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { @@ -8108,7 +8066,7 @@ func (c *Client) GetPersonProfileHistoryWithResponse(ctx context.Context, option StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetPersonProfileHistoryErrorResponseJSON503", + TargetType: "ListPersonAttributesErrorResponseJSON", Body: bodyBytes, Err: err, } @@ -8120,12 +8078,12 @@ func (c *Client) GetPersonProfileHistoryWithResponse(ctx context.Context, option } } -// 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) { +// ClearPersonAttribute Supersede a person's attribute value +func (c *Client) ClearPersonAttributeWithResponse(ctx context.Context, options *ClearPersonAttributeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ClearPersonAttributeResp, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/profile/media/{media_id}/content", - Method: "GET", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/attributes/{slug}", + Method: "DELETE", Options: options, } @@ -8134,12 +8092,12 @@ func (c *Client) GetPersonProfileMediaContentWithResponse(ctx context.Context, o 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") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/persons/{id}/attributes/{slug}") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } - out := &GetPersonProfileMediaContentResp{ + out := &ClearPersonAttributeResp{ HTTPResponse: resp.Raw, Body: resp.Content, StatusCode: resp.StatusCode, @@ -8147,33 +8105,31 @@ func (c *Client) GetPersonProfileMediaContentWithResponse(ctx context.Context, o switch resp.StatusCode { case 200: - return out, nil - case 400: - out.JSON400 = new(GetPersonProfileMediaContentErrorResponse) + out.JSON200 = new(ClearPersonAttributeResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { - if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + 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: "GetPersonProfileMediaContentErrorResponse", + TargetType: "ClearPersonAttributeResponse", 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) + return out, nil + case 400: + out.JSON400 = new(ClearPersonAttributeErrorResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { - if err := json.Unmarshal(bodyBytes, out.JSON401); err != nil { + 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: "GetPersonProfileMediaContentErrorResponseJSON", + TargetType: "ClearPersonAttributeErrorResponse", Body: bodyBytes, Err: err, } @@ -8181,7 +8137,7 @@ func (c *Client) GetPersonProfileMediaContentWithResponse(ctx context.Context, o } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 404: - out.JSON404 = new(GetPersonProfileMediaContentErrorResponseJSON404) + out.JSON404 = new(ClearPersonAttributeErrorResponseJSON) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { @@ -8189,23 +8145,23 @@ func (c *Client) GetPersonProfileMediaContentWithResponse(ctx context.Context, o StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetPersonProfileMediaContentErrorResponseJSON404", + TargetType: "ClearPersonAttributeErrorResponseJSON", 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) + case 409: + out.JSON409 = new(ClearPersonAttributeErrorResponseJSON409) bodyBytes := resp.Content if len(bodyBytes) > 0 { - if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { + 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: "GetPersonProfileMediaContentErrorResponseJSON500", + TargetType: "ClearPersonAttributeErrorResponseJSON409", Body: bodyBytes, Err: err, } @@ -8213,7 +8169,7 @@ func (c *Client) GetPersonProfileMediaContentWithResponse(ctx context.Context, o } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 503: - out.JSON503 = new(GetPersonProfileMediaContentErrorResponseJSON503) + out.JSON503 = new(ClearPersonAttributeErrorResponseJSON503) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { @@ -8221,7 +8177,651 @@ func (c *Client) GetPersonProfileMediaContentWithResponse(ctx context.Context, o StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetPersonProfileMediaContentErrorResponseJSON503", + TargetType: "ClearPersonAttributeErrorResponseJSON503", + 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)) + } +} + +// SetPersonAttribute Set a person's attribute value +func (c *Client) SetPersonAttributeWithResponse(ctx context.Context, options *SetPersonAttributeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SetPersonAttributeResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/attributes/{slug}", + Method: "PUT", + 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}/attributes/{slug}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &SetPersonAttributeResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(SetPersonAttributeResponse) + 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: "SetPersonAttributeResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 400: + out.JSON400 = new(SetPersonAttributeErrorResponse) + 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: "SetPersonAttributeErrorResponse", + 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(SetPersonAttributeErrorResponseJSON) + 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: "SetPersonAttributeErrorResponseJSON", + 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(SetPersonAttributeErrorResponseJSON409) + 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: "SetPersonAttributeErrorResponseJSON409", + 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(SetPersonAttributeErrorResponseJSON503) + 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: "SetPersonAttributeErrorResponseJSON503", + 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)) + } +} + +// 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)) + } +} + +// ListPersonRelationships List one person's relationships +func (c *Client) ListPersonRelationshipsWithResponse(ctx context.Context, options *ListPersonRelationshipsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonRelationshipsResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/persons/{id}/relationships", + 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}/relationships") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &ListPersonRelationshipsResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(ListPersonRelationshipsResponse) + 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: "ListPersonRelationshipsResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 400: + out.JSON400 = new(ListPersonRelationshipsErrorResponse) + 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: "ListPersonRelationshipsErrorResponse", + 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(ListPersonRelationshipsErrorResponseJSON) + 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: "ListPersonRelationshipsErrorResponseJSON", Body: bodyBytes, Err: err, } @@ -8237,8 +8837,415 @@ func (c *Client) GetPersonProfileMediaContentWithResponse(ctx context.Context, o func (c *Client) RunQueryWithResponse(ctx context.Context, options *RunQueryRequestOptions, reqEditors ...runtime.RequestEditorFn) (*RunQueryResp, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/query", - Method: "POST", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/query", + 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/query") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &RunQueryResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(RunQueryResponse) + 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: "RunQueryResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 500: + 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)) + } +} + +// ListRelationshipTypes List person relationship types +func (c *Client) ListRelationshipTypesWithResponse(ctx context.Context, reqEditors ...runtime.RequestEditorFn) (*ListRelationshipTypesResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/relationship-types", + Method: "GET", + } + + 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/relationship-types") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &ListRelationshipTypesResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(ListRelationshipTypesResponse) + 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: "ListRelationshipTypesResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 503: + out.JSON503 = new(ListRelationshipTypesErrorResponse) + 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: "ListRelationshipTypesErrorResponse", + 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)) + } +} + +// CreateRelationshipType Create a user-owned relationship type +func (c *Client) CreateRelationshipTypeWithResponse(ctx context.Context, options *CreateRelationshipTypeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CreateRelationshipTypeResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/relationship-types", + 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/relationship-types") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &CreateRelationshipTypeResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 201: + out.JSON201 = new(CreateRelationshipTypeResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON201); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CreateRelationshipTypeResponse", + Body: bodyBytes, + Err: err, + } + } + } + out.Headers201 = &CreateRelationshipTypeResp201Headers{ + ETag: resp.Headers.Get("ETag"), + Location: resp.Headers.Get("Location"), + } + return out, nil + case 400: + out.JSON400 = new(CreateRelationshipTypeErrorResponse) + 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: "CreateRelationshipTypeErrorResponse", + 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(CreateRelationshipTypeErrorResponseJSON) + 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: "CreateRelationshipTypeErrorResponseJSON", + 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(CreateRelationshipTypeErrorResponseJSON503) + 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: "CreateRelationshipTypeErrorResponseJSON503", + 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)) + } +} + +// DeleteRelationshipType Delete an unused relationship type +func (c *Client) DeleteRelationshipTypeWithResponse(ctx context.Context, options *DeleteRelationshipTypeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DeleteRelationshipTypeResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/relationship-types/{id}", + Method: "DELETE", + 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/relationship-types/{id}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &DeleteRelationshipTypeResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 204: + return out, nil + case 400: + out.JSON400 = new(DeleteRelationshipTypeErrorResponse) + 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: "DeleteRelationshipTypeErrorResponse", + 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(DeleteRelationshipTypeErrorResponseJSON) + 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: "DeleteRelationshipTypeErrorResponseJSON", + 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(DeleteRelationshipTypeErrorResponseJSON409) + 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: "DeleteRelationshipTypeErrorResponseJSON409", + 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(DeleteRelationshipTypeErrorResponseJSON428) + 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: "DeleteRelationshipTypeErrorResponseJSON428", + 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(DeleteRelationshipTypeErrorResponseJSON503) + 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: "DeleteRelationshipTypeErrorResponseJSON503", + 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)) + } +} + +// GetRelationshipType Get a relationship type +func (c *Client) GetRelationshipTypeWithResponse(ctx context.Context, options *GetRelationshipTypeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetRelationshipTypeResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/relationship-types/{id}", + 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/relationship-types/{id}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &GetRelationshipTypeResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(GetRelationshipTypeResponse) + 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: "GetRelationshipTypeResponse", + Body: bodyBytes, + Err: err, + } + } + } + out.Headers200 = &GetRelationshipTypeResp200Headers{ + ETag: resp.Headers.Get("ETag"), + } + return out, nil + case 404: + out.JSON404 = new(GetRelationshipTypeErrorResponse) + 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: "GetRelationshipTypeErrorResponse", + 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(GetRelationshipTypeErrorResponseJSON) + 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: "GetRelationshipTypeErrorResponseJSON", + 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)) + } +} + +// PatchRelationshipType Update a relationship type +func (c *Client) PatchRelationshipTypeWithResponse(ctx context.Context, options *PatchRelationshipTypeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PatchRelationshipTypeResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/relationship-types/{id}", + Method: "PATCH", Options: options, ContentType: "application/json", } @@ -8248,12 +9255,12 @@ func (c *Client) RunQueryWithResponse(ctx context.Context, options *RunQueryRequ return nil, fmt.Errorf("error creating request: %w", err) } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/query") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/relationship-types/{id}") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } - out := &RunQueryResp{ + out := &PatchRelationshipTypeResp{ HTTPResponse: resp.Raw, Body: resp.Content, StatusCode: resp.StatusCode, @@ -8261,7 +9268,7 @@ func (c *Client) RunQueryWithResponse(ctx context.Context, options *RunQueryRequ switch resp.StatusCode { case 200: - out.JSON200 = new(RunQueryResponse) + out.JSON200 = new(PatchRelationshipTypeResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { @@ -8269,14 +9276,95 @@ func (c *Client) RunQueryWithResponse(ctx context.Context, options *RunQueryRequ StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "RunQueryResponse", + TargetType: "PatchRelationshipTypeResponse", Body: bodyBytes, Err: err, } } } + out.Headers200 = &PatchRelationshipTypeResp200Headers{ + ETag: resp.Headers.Get("ETag"), + } return out, nil - case 500: + case 400: + out.JSON400 = new(PatchRelationshipTypeErrorResponse) + 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: "PatchRelationshipTypeErrorResponse", + 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(PatchRelationshipTypeErrorResponseJSON) + 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: "PatchRelationshipTypeErrorResponseJSON", + 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(PatchRelationshipTypeErrorResponseJSON409) + 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: "PatchRelationshipTypeErrorResponseJSON409", + 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(PatchRelationshipTypeErrorResponseJSON428) + 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: "PatchRelationshipTypeErrorResponseJSON428", + 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(PatchRelationshipTypeErrorResponseJSON503) + 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: "PatchRelationshipTypeErrorResponseJSON503", + 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)) diff --git a/pkg/client/generated/enums.go b/pkg/client/generated/enums.go index ae8281e51..f79fca51f 100644 --- a/pkg/client/generated/enums.go +++ b/pkg/client/generated/enums.go @@ -672,3 +672,21 @@ func (t TaskIntegrationStatusResponseState) Validate() error { return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid TaskIntegrationStatusResponseState value, got: %v", t)) } } + +type ListPersonRelationshipReviewsQueryStatus string + +const ( + Accepted ListPersonRelationshipReviewsQueryStatus = "accepted" + Pending ListPersonRelationshipReviewsQueryStatus = "pending" + Rejected ListPersonRelationshipReviewsQueryStatus = "rejected" +) + +// Validate checks if the ListPersonRelationshipReviewsQueryStatus value is valid +func (l ListPersonRelationshipReviewsQueryStatus) Validate() error { + switch l { + case Accepted, Pending, Rejected: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid ListPersonRelationshipReviewsQueryStatus value, got: %v", l)) + } +} diff --git a/pkg/client/generated/headers.go b/pkg/client/generated/headers.go index b3366635a..b04a767ad 100644 --- a/pkg/client/generated/headers.go +++ b/pkg/client/generated/headers.go @@ -33,6 +33,24 @@ func (c CreateOrLinkMessageTaskHeaders) Validate() error { return runtime.ConvertValidatorError(typesValidator.Struct(c)) } +type DeletePersonRelationshipHeaders struct { + // IfMatch Strong ETag returned by the latest person relationship read + IfMatch string `json:"If-Match" validate:"required"` +} + +func (d DeletePersonRelationshipHeaders) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(d)) +} + +type PatchPersonRelationshipHeaders struct { + // IfMatch Strong ETag returned by the latest person relationship read + IfMatch string `json:"If-Match" validate:"required"` +} + +func (p PatchPersonRelationshipHeaders) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + type DeletePersonHeaders 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"` @@ -60,6 +78,24 @@ func (p PatchPersonStructuredProfileHeaders) Validate() error { return runtime.ConvertValidatorError(typesValidator.Struct(p)) } +type DeleteRelationshipTypeHeaders struct { + // IfMatch Strong ETag returned by the latest relationship type read + IfMatch string `json:"If-Match" validate:"required"` +} + +func (d DeleteRelationshipTypeHeaders) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(d)) +} + +type PatchRelationshipTypeHeaders struct { + // IfMatch Strong ETag returned by the latest relationship type read + IfMatch string `json:"If-Match" validate:"required"` +} + +func (p PatchRelationshipTypeHeaders) 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 0619e6271..0a71988c5 100644 --- a/pkg/client/generated/paths.go +++ b/pkg/client/generated/paths.go @@ -192,6 +192,21 @@ type GetPersonTimelinePath struct { ID int64 `json:"id"` } +type DeletePersonRelationshipPath struct { + // ID Person relationship ID + ID int64 `json:"id"` +} + +type GetPersonRelationshipPath struct { + // ID Person relationship ID + ID int64 `json:"id"` +} + +type PatchPersonRelationshipPath struct { + // ID Person relationship ID + ID int64 `json:"id"` +} + type DeletePersonPath struct { // ID Durable person ID ID int64 `json:"id"` @@ -259,6 +274,26 @@ type GetPersonProfileMediaContentPath struct { MediaID int64 `json:"media_id"` } +type ListPersonRelationshipsPath struct { + // ID Durable person ID + ID int64 `json:"id"` +} + +type DeleteRelationshipTypePath struct { + // ID Relationship type ID + ID int64 `json:"id"` +} + +type GetRelationshipTypePath struct { + // ID Relationship type ID + ID int64 `json:"id"` +} + +type PatchRelationshipTypePath struct { + // ID Relationship type ID + ID int64 `json:"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 767121ce2..ca11413a5 100644 --- a/pkg/client/generated/payloads.go +++ b/pkg/client/generated/payloads.go @@ -90,6 +90,10 @@ type GetPersonContextSummaryBody = ExploreHTTPRequest type GetPersonTimelineBody = ExploreHTTPRequest +type CreatePersonRelationshipBody = CreatePersonRelationshipRequest + +type PatchPersonRelationshipBody = PatchPersonRelationshipRequest + type CreatePersonBody = CreatePersonRequest type PatchPersonBody = PatchPersonRequest @@ -100,6 +104,10 @@ type PatchPersonStructuredProfileBody = PersonProfilePatchRequest type RunQueryBody = QueryRequest +type CreateRelationshipTypeBody = CreateRelationshipTypeRequest + +type PatchRelationshipTypeBody = PatchRelationshipTypeRequest + type ListRelationshipsBody = RelationshipsHTTPRequest type GetRelationshipTimelineBody = RelationshipTimelineHTTPRequest diff --git a/pkg/client/generated/queries.go b/pkg/client/generated/queries.go index afd9aa177..28fec965f 100644 --- a/pkg/client/generated/queries.go +++ b/pkg/client/generated/queries.go @@ -448,6 +448,26 @@ func (g GetMessageInlinePartQuery) Validate() error { return runtime.ConvertValidatorError(typesValidator.Struct(g)) } +type ListPersonRelationshipReviewsQuery struct { + Status *ListPersonRelationshipReviewsQueryStatus `json:"status,omitempty"` + PersonID *int64 `json:"person_id,omitempty"` +} + +func (l ListPersonRelationshipReviewsQuery) Validate() error { + var errors runtime.ValidationErrors + if l.Status != nil { + if v, ok := any(l.Status).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Status", err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + type ListPersonAttributesQuery struct { // History Include superseded values History *bool `json:"history,omitempty"` @@ -472,6 +492,10 @@ type SetPersonAttributeQuery struct { DryRun *bool `json:"dry_run,omitempty"` } +type ListPersonRelationshipsQuery struct { + IncludeEnded *bool `json:"include_ended,omitempty"` +} + type SearchMessagesQuery struct { // Q Search query Q string `json:"q" validate:"required"` diff --git a/pkg/client/generated/responses.go b/pkg/client/generated/responses.go index cfe83d31f..635df25da 100644 --- a/pkg/client/generated/responses.go +++ b/pkg/client/generated/responses.go @@ -1165,6 +1165,50 @@ func (g *GetPersonTimelineErrorResponseJSON503) UnmarshalJSON(data []byte) error return nil } +type ListPersonRelationshipReviewsResponse = RelationshipReviewsResponse + +type ListPersonRelationshipReviewsErrorResponse = ErrorResponse + +type ListPersonRelationshipReviewsErrorResponseJSON = ErrorResponse + +type CreatePersonRelationshipResponse = PersonRelationship + +type CreatePersonRelationshipErrorResponse = ErrorResponse + +type CreatePersonRelationshipErrorResponseJSON = ErrorResponse + +type CreatePersonRelationshipErrorResponseJSON409 = ErrorResponse + +type CreatePersonRelationshipErrorResponseJSON503 = ErrorResponse + +type DeletePersonRelationshipErrorResponse = ErrorResponse + +type DeletePersonRelationshipErrorResponseJSON = ErrorResponse + +type DeletePersonRelationshipErrorResponseJSON409 = ErrorResponse + +type DeletePersonRelationshipErrorResponseJSON428 = ErrorResponse + +type DeletePersonRelationshipErrorResponseJSON503 = ErrorResponse + +type GetPersonRelationshipResponse = PersonRelationship + +type GetPersonRelationshipErrorResponse = ErrorResponse + +type GetPersonRelationshipErrorResponseJSON = ErrorResponse + +type PatchPersonRelationshipResponse = PersonRelationship + +type PatchPersonRelationshipErrorResponse = ErrorResponse + +type PatchPersonRelationshipErrorResponseJSON = ErrorResponse + +type PatchPersonRelationshipErrorResponseJSON409 = ErrorResponse + +type PatchPersonRelationshipErrorResponseJSON428 = ErrorResponse + +type PatchPersonRelationshipErrorResponseJSON503 = ErrorResponse + type ListPersonsResponse = PersonsResponse type ListPersonsErrorResponse = ErrorResponse @@ -1275,10 +1319,56 @@ type GetPersonProfileMediaContentErrorResponseJSON500 = ErrorResponse type GetPersonProfileMediaContentErrorResponseJSON503 = ErrorResponse +type ListPersonRelationshipsResponse = PersonRelationshipsResponse + +type ListPersonRelationshipsErrorResponse = ErrorResponse + +type ListPersonRelationshipsErrorResponseJSON = ErrorResponse + type RunQueryResponse = QueryResult type RunQueryErrorResponse = ErrorResponse +type ListRelationshipTypesResponse = RelationshipTypesResponse + +type ListRelationshipTypesErrorResponse = ErrorResponse + +type CreateRelationshipTypeResponse = RelationshipType + +type CreateRelationshipTypeErrorResponse = ErrorResponse + +type CreateRelationshipTypeErrorResponseJSON = ErrorResponse + +type CreateRelationshipTypeErrorResponseJSON503 = ErrorResponse + +type DeleteRelationshipTypeErrorResponse = ErrorResponse + +type DeleteRelationshipTypeErrorResponseJSON = ErrorResponse + +type DeleteRelationshipTypeErrorResponseJSON409 = ErrorResponse + +type DeleteRelationshipTypeErrorResponseJSON428 = ErrorResponse + +type DeleteRelationshipTypeErrorResponseJSON503 = ErrorResponse + +type GetRelationshipTypeResponse = RelationshipType + +type GetRelationshipTypeErrorResponse = ErrorResponse + +type GetRelationshipTypeErrorResponseJSON = ErrorResponse + +type PatchRelationshipTypeResponse = RelationshipType + +type PatchRelationshipTypeErrorResponse = ErrorResponse + +type PatchRelationshipTypeErrorResponseJSON = ErrorResponse + +type PatchRelationshipTypeErrorResponseJSON409 = ErrorResponse + +type PatchRelationshipTypeErrorResponseJSON428 = ErrorResponse + +type PatchRelationshipTypeErrorResponseJSON503 = ErrorResponse + type ListRelationshipsResponse = RelationshipsHTTPResponse type ListRelationshipsErrorResponse = ErrorResponse @@ -2362,6 +2452,74 @@ type GetPersonTimelineResp struct { JSON503 *GetPersonTimelineErrorResponseJSON503 } +type ListPersonRelationshipReviewsResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *ListPersonRelationshipReviewsResponse + JSON400 *ListPersonRelationshipReviewsErrorResponse + JSON503 *ListPersonRelationshipReviewsErrorResponseJSON +} + +type CreatePersonRelationshipResp201Headers struct { + ETag string `header:"ETag"` + Location string `header:"Location"` +} + +type CreatePersonRelationshipResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON201 *CreatePersonRelationshipResponse + Headers201 *CreatePersonRelationshipResp201Headers + JSON400 *CreatePersonRelationshipErrorResponse + JSON404 *CreatePersonRelationshipErrorResponseJSON + JSON409 *CreatePersonRelationshipErrorResponseJSON409 + JSON503 *CreatePersonRelationshipErrorResponseJSON503 +} + +type DeletePersonRelationshipResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON400 *DeletePersonRelationshipErrorResponse + JSON404 *DeletePersonRelationshipErrorResponseJSON + JSON409 *DeletePersonRelationshipErrorResponseJSON409 + JSON428 *DeletePersonRelationshipErrorResponseJSON428 + JSON503 *DeletePersonRelationshipErrorResponseJSON503 +} + +type GetPersonRelationshipResp200Headers struct { + ETag string `header:"ETag"` +} + +type GetPersonRelationshipResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *GetPersonRelationshipResponse + Headers200 *GetPersonRelationshipResp200Headers + JSON404 *GetPersonRelationshipErrorResponse + JSON503 *GetPersonRelationshipErrorResponseJSON +} + +type PatchPersonRelationshipResp200Headers struct { + ETag string `header:"ETag"` +} + +type PatchPersonRelationshipResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *PatchPersonRelationshipResponse + Headers200 *PatchPersonRelationshipResp200Headers + JSON400 *PatchPersonRelationshipErrorResponse + JSON404 *PatchPersonRelationshipErrorResponseJSON + JSON409 *PatchPersonRelationshipErrorResponseJSON409 + JSON428 *PatchPersonRelationshipErrorResponseJSON428 + JSON503 *PatchPersonRelationshipErrorResponseJSON503 +} + type ListPersonsResp struct { HTTPResponse *http.Response Body []byte @@ -2518,6 +2676,15 @@ type GetPersonProfileMediaContentResp struct { JSON503 *GetPersonProfileMediaContentErrorResponseJSON503 } +type ListPersonRelationshipsResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *ListPersonRelationshipsResponse + JSON400 *ListPersonRelationshipsErrorResponse + JSON503 *ListPersonRelationshipsErrorResponseJSON +} + type RunQueryResp struct { HTTPResponse *http.Response Body []byte @@ -2525,6 +2692,72 @@ type RunQueryResp struct { JSON200 *RunQueryResponse } +type ListRelationshipTypesResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *ListRelationshipTypesResponse + JSON503 *ListRelationshipTypesErrorResponse +} + +type CreateRelationshipTypeResp201Headers struct { + ETag string `header:"ETag"` + Location string `header:"Location"` +} + +type CreateRelationshipTypeResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON201 *CreateRelationshipTypeResponse + Headers201 *CreateRelationshipTypeResp201Headers + JSON400 *CreateRelationshipTypeErrorResponse + JSON409 *CreateRelationshipTypeErrorResponseJSON + JSON503 *CreateRelationshipTypeErrorResponseJSON503 +} + +type DeleteRelationshipTypeResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON400 *DeleteRelationshipTypeErrorResponse + JSON404 *DeleteRelationshipTypeErrorResponseJSON + JSON409 *DeleteRelationshipTypeErrorResponseJSON409 + JSON428 *DeleteRelationshipTypeErrorResponseJSON428 + JSON503 *DeleteRelationshipTypeErrorResponseJSON503 +} + +type GetRelationshipTypeResp200Headers struct { + ETag string `header:"ETag"` +} + +type GetRelationshipTypeResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *GetRelationshipTypeResponse + Headers200 *GetRelationshipTypeResp200Headers + JSON404 *GetRelationshipTypeErrorResponse + JSON503 *GetRelationshipTypeErrorResponseJSON +} + +type PatchRelationshipTypeResp200Headers struct { + ETag string `header:"ETag"` +} + +type PatchRelationshipTypeResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *PatchRelationshipTypeResponse + Headers200 *PatchRelationshipTypeResp200Headers + JSON400 *PatchRelationshipTypeErrorResponse + JSON404 *PatchRelationshipTypeErrorResponseJSON + JSON409 *PatchRelationshipTypeErrorResponseJSON409 + JSON428 *PatchRelationshipTypeErrorResponseJSON428 + JSON503 *PatchRelationshipTypeErrorResponseJSON503 +} + type ListRelationshipsResp struct { HTTPResponse *http.Response Body []byte diff --git a/pkg/client/generated/types.go b/pkg/client/generated/types.go index 76afe4371..e6da6895c 100644 --- a/pkg/client/generated/types.go +++ b/pkg/client/generated/types.go @@ -1320,10 +1320,38 @@ func (c CreateCommunicationServiceRequest) Validate() error { return errors } +type CreatePersonRelationshipRequest struct { + EndDate *string `json:"end_date,omitempty"` + Notes *string `json:"notes,omitempty"` + RelationshipTypeSlug string `json:"relationship_type_slug" validate:"required"` + SourcePersonID int64 `json:"source_person_id"` + StartDate *string `json:"start_date,omitempty"` + TargetPersonID int64 `json:"target_person_id"` +} + +func (c CreatePersonRelationshipRequest) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(c)) +} + type CreatePersonRequest struct { ParticipantID int64 `json:"participant_id"` } +type CreateRelationshipTypeRequest struct { + Color *string `json:"color,omitempty"` + Description *string `json:"description,omitempty"` + ForwardLabel string `json:"forward_label" validate:"required"` + Icon *string `json:"icon,omitempty"` + IsSymmetric *bool `json:"is_symmetric,omitempty"` + ReverseLabel string `json:"reverse_label" validate:"required"` + Slug string `json:"slug" validate:"required"` + VcardRelatedType *string `json:"vcard_related_type,omitempty"` +} + +func (c CreateRelationshipTypeRequest) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(c)) +} + type CreateRequest struct { Accounts []string `json:"accounts,omitempty" validate:"required"` Name string `json:"name" validate:"required"` @@ -3304,6 +3332,11 @@ type PatchAttributeDefinitionRequest struct { Label *string `json:"label,omitempty"` } +type PatchPersonRelationshipRequest struct { + EndDate *string `json:"end_date,omitempty"` + Notes *string `json:"notes,omitempty"` +} + type PatchPersonRequest struct { DisplayName *string `json:"display_name" validate:"omitempty"` } @@ -3312,6 +3345,15 @@ func (p PatchPersonRequest) Validate() error { return runtime.ConvertValidatorError(typesValidator.Struct(p)) } +type PatchRelationshipTypeRequest struct { + Color *string `json:"color,omitempty"` + Description *string `json:"description,omitempty"` + ForwardLabel *string `json:"forward_label,omitempty"` + Icon *string `json:"icon,omitempty"` + ReverseLabel *string `json:"reverse_label,omitempty"` + VcardRelatedType *string `json:"vcard_related_type,omitempty"` +} + type PatchSavedViewRequest struct { CanonicalState *SavedViewStateEnvelope `json:"canonical_state,omitempty"` Description *string `json:"description,omitempty"` @@ -4190,6 +4232,134 @@ func (p PersonProfilePatchRequest) Validate() error { return errors } +type PersonRelationship struct { + Confidence *float64 `json:"confidence,omitempty"` + CreatedAt time.Time `json:"created_at" validate:"required"` + CreatedBy string `json:"created_by" validate:"required"` + EndDate *PartialDate `json:"end_date,omitempty"` + ForwardLabel string `json:"forward_label" validate:"required"` + ID int64 `json:"id"` + IsSymmetric bool `json:"is_symmetric"` + Notes *string `json:"notes,omitempty"` + RelationshipTypeID int64 `json:"relationship_type_id"` + ReverseLabel string `json:"reverse_label" validate:"required"` + Revision int64 `json:"revision"` + Source string `json:"source" validate:"required"` + SourcePersonID int64 `json:"source_person_id"` + SourceRef *string `json:"source_ref,omitempty"` + StartDate *PartialDate `json:"start_date,omitempty"` + Status string `json:"status" validate:"required"` + TargetPersonID int64 `json:"target_person_id"` + TypeSlug string `json:"type_slug" validate:"required"` + UpdatedAt time.Time `json:"updated_at" validate:"required"` + UpdatedBy string `json:"updated_by" validate:"required"` + VcardIdentity VCardIdentity `json:"vcard_identity"` +} + +func (p PersonRelationship) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(p.CreatedAt, "required"); err != nil { + errors = errors.Append("CreatedAt", err) + } + if err := typesValidator.Var(p.CreatedBy, "required"); err != nil { + errors = errors.Append("CreatedBy", err) + } + if p.EndDate != nil { + if v, ok := any(p.EndDate).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("EndDate", err) + } + } + } + if err := typesValidator.Var(p.ForwardLabel, "required"); err != nil { + errors = errors.Append("ForwardLabel", err) + } + if err := typesValidator.Var(p.ReverseLabel, "required"); err != nil { + errors = errors.Append("ReverseLabel", err) + } + if err := typesValidator.Var(p.Source, "required"); err != nil { + errors = errors.Append("Source", err) + } + if p.StartDate != nil { + if v, ok := any(p.StartDate).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("StartDate", err) + } + } + } + if err := typesValidator.Var(p.Status, "required"); err != nil { + errors = errors.Append("Status", err) + } + if err := typesValidator.Var(p.TypeSlug, "required"); err != nil { + errors = errors.Append("TypeSlug", err) + } + if err := typesValidator.Var(p.UpdatedAt, "required"); err != nil { + errors = errors.Append("UpdatedAt", err) + } + if err := typesValidator.Var(p.UpdatedBy, "required"); err != nil { + errors = errors.Append("UpdatedBy", err) + } + if v, ok := any(p.VcardIdentity).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("VcardIdentity", err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonRelationshipView struct { + CounterpartDisplayName *string `json:"counterpart_display_name,omitempty"` + CounterpartLabel string `json:"counterpart_label" validate:"required"` + CounterpartPersonID int64 `json:"counterpart_person_id"` + CounterpartVcardUID string `json:"counterpart_vcard_uid" validate:"required"` + Direction string `json:"direction" validate:"required"` + Relationship PersonRelationship `json:"relationship"` +} + +func (p PersonRelationshipView) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(p.CounterpartLabel, "required"); err != nil { + errors = errors.Append("CounterpartLabel", err) + } + if err := typesValidator.Var(p.CounterpartVcardUID, "required"); err != nil { + errors = errors.Append("CounterpartVcardUID", err) + } + if err := typesValidator.Var(p.Direction, "required"); err != nil { + errors = errors.Append("Direction", err) + } + if v, ok := any(p.Relationship).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Relationship", err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonRelationshipsResponse struct { + Relationships []PersonRelationshipView `json:"relationships,omitempty" validate:"required"` +} + +func (p PersonRelationshipsResponse) Validate() error { + var errors runtime.ValidationErrors + for i, item := range p.Relationships { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Relationships[%d]", i), 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"` @@ -4344,6 +4514,81 @@ func (r RejectedCandidate) Validate() error { return runtime.ConvertValidatorError(typesValidator.Struct(r)) } +type RelationshipReview struct { + AcceptedRelationshipID *int64 `json:"accepted_relationship_id,omitempty"` + CreatedAt time.Time `json:"created_at" validate:"required"` + CreatedBy string `json:"created_by" validate:"required"` + ID int64 `json:"id"` + MatchedPersonID *int64 `json:"matched_person_id,omitempty"` + PersonID int64 `json:"person_id"` + RawRelatedType string `json:"raw_related_type" validate:"required"` + RawRelatedValue string `json:"raw_related_value" validate:"required"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty"` + ReviewedBy *string `json:"reviewed_by,omitempty"` + Source string `json:"source" validate:"required"` + SourceRef *string `json:"source_ref,omitempty"` + Status string `json:"status" validate:"required"` + UpdatedAt time.Time `json:"updated_at" validate:"required"` + ValueKind string `json:"value_kind" validate:"required"` + VcardIdentity VCardIdentity `json:"vcard_identity"` +} + +func (r RelationshipReview) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(r.CreatedAt, "required"); err != nil { + errors = errors.Append("CreatedAt", err) + } + if err := typesValidator.Var(r.CreatedBy, "required"); err != nil { + errors = errors.Append("CreatedBy", err) + } + if err := typesValidator.Var(r.RawRelatedType, "required"); err != nil { + errors = errors.Append("RawRelatedType", err) + } + if err := typesValidator.Var(r.RawRelatedValue, "required"); err != nil { + errors = errors.Append("RawRelatedValue", err) + } + if err := typesValidator.Var(r.Source, "required"); err != nil { + errors = errors.Append("Source", err) + } + if err := typesValidator.Var(r.Status, "required"); err != nil { + errors = errors.Append("Status", err) + } + if err := typesValidator.Var(r.UpdatedAt, "required"); err != nil { + errors = errors.Append("UpdatedAt", err) + } + if err := typesValidator.Var(r.ValueKind, "required"); err != nil { + errors = errors.Append("ValueKind", err) + } + if v, ok := any(r.VcardIdentity).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("VcardIdentity", err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type RelationshipReviewsResponse struct { + Reviews []RelationshipReview `json:"reviews,omitempty" validate:"required"` +} + +func (r RelationshipReviewsResponse) Validate() error { + var errors runtime.ValidationErrors + for i, item := range r.Reviews { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Reviews[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + type RelationshipRow struct { CanonicalID int64 `json:"canonical_id"` DisplayLabel string `json:"display_label" validate:"required"` @@ -4443,6 +4688,49 @@ func (r RelationshipTimelineHTTPResponse) Validate() error { return errors } +type RelationshipType struct { + Color *string `json:"color,omitempty"` + CreatedAt time.Time `json:"created_at" validate:"required"` + Description *string `json:"description,omitempty"` + ForwardLabel string `json:"forward_label" validate:"required"` + Icon *string `json:"icon,omitempty"` + ID int64 `json:"id"` + InverseTypeID *int64 `json:"inverse_type_id,omitempty"` + IsCanonical bool `json:"is_canonical"` + IsDeletable bool `json:"is_deletable"` + IsSymmetric bool `json:"is_symmetric"` + Ownership string `json:"ownership" validate:"required"` + ReverseLabel string `json:"reverse_label" validate:"required"` + Revision int64 `json:"revision"` + Slug string `json:"slug" validate:"required"` + UniversalID string `json:"universal_id" validate:"required"` + UpdatedAt time.Time `json:"updated_at" validate:"required"` + VcardRelatedType *string `json:"vcard_related_type,omitempty"` +} + +func (r RelationshipType) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(r)) +} + +type RelationshipTypesResponse struct { + RelationshipTypes []RelationshipType `json:"relationship_types,omitempty" validate:"required"` +} + +func (r RelationshipTypesResponse) Validate() error { + var errors runtime.ValidationErrors + for i, item := range r.RelationshipTypes { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("RelationshipTypes[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + type RelationshipsHTTPRequest struct { Cursor *string `json:"cursor,omitempty"` Filters []ExploreFilter `json:"filters,omitempty"` diff --git a/pkg/client/openapi.yaml b/pkg/client/openapi.yaml index f4a135962..c2f4dd1fd 100644 --- a/pkg/client/openapi.yaml +++ b/pkg/client/openapi.yaml @@ -1498,6 +1498,29 @@ components: - scope_policy - normalization type: object + CreatePersonRelationshipRequest: + additionalProperties: false + properties: + end_date: + type: string + notes: + nullable: true + type: string + relationship_type_slug: + type: string + source_person_id: + format: int64 + type: integer + start_date: + type: string + target_person_id: + format: int64 + type: integer + required: + - source_person_id + - target_person_id + - relationship_type_slug + type: object CreatePersonRequest: additionalProperties: false properties: @@ -1507,6 +1530,34 @@ components: required: - participant_id type: object + CreateRelationshipTypeRequest: + additionalProperties: false + properties: + color: + nullable: true + type: string + description: + nullable: true + type: string + forward_label: + type: string + icon: + nullable: true + type: string + is_symmetric: + type: boolean + reverse_label: + type: string + slug: + type: string + vcard_related_type: + nullable: true + type: string + required: + - slug + - forward_label + - reverse_label + type: object CreateRequest: additionalProperties: false properties: @@ -3513,6 +3564,16 @@ components: label: type: string type: object + PatchPersonRelationshipRequest: + additionalProperties: false + minProperties: 1 + properties: + end_date: + type: string + notes: + nullable: true + type: string + type: object PatchPersonRequest: additionalProperties: false properties: @@ -3525,6 +3586,23 @@ components: required: - display_name type: object + PatchRelationshipTypeRequest: + additionalProperties: false + minProperties: 1 + properties: + color: + type: string + description: + type: string + forward_label: + type: string + icon: + type: string + reverse_label: + type: string + vcard_related_type: + type: string + type: object PatchSavedViewRequest: additionalProperties: false properties: @@ -4256,6 +4334,108 @@ components: names: $ref: "#/components/schemas/PersonNamePatchRequest" type: object + PersonRelationship: + properties: + confidence: + format: double + type: number + created_at: + format: date-time + type: string + created_by: + type: string + end_date: + $ref: "#/components/schemas/PartialDate" + forward_label: + type: string + id: + format: int64 + type: integer + is_symmetric: + type: boolean + notes: + type: string + relationship_type_id: + format: int64 + type: integer + reverse_label: + type: string + revision: + format: int64 + type: integer + source: + type: string + source_person_id: + format: int64 + type: integer + source_ref: + type: string + start_date: + $ref: "#/components/schemas/PartialDate" + status: + type: string + target_person_id: + format: int64 + type: integer + type_slug: + type: string + updated_at: + format: date-time + type: string + updated_by: + type: string + vcard_identity: + $ref: "#/components/schemas/VCardIdentity" + required: + - id + - source_person_id + - target_person_id + - relationship_type_id + - type_slug + - forward_label + - reverse_label + - is_symmetric + - status + - source + - vcard_identity + - created_by + - updated_by + - revision + - created_at + - updated_at + type: object + PersonRelationshipView: + properties: + counterpart_display_name: + type: string + counterpart_label: + type: string + counterpart_person_id: + format: int64 + type: integer + counterpart_vcard_uid: + type: string + direction: + type: string + relationship: + $ref: "#/components/schemas/PersonRelationship" + required: + - relationship + - direction + - counterpart_person_id + - counterpart_label + - counterpart_vcard_uid + type: object + PersonRelationshipsResponse: + properties: + relationships: + items: + $ref: "#/components/schemas/PersonRelationshipView" + nullable: true + type: array + required: + - relationships + type: object PersonSearchHTTPResponse: properties: cache_revision: @@ -4408,6 +4588,70 @@ components: - identifier - reason type: object + RelationshipReview: + properties: + accepted_relationship_id: + format: int64 + type: integer + created_at: + format: date-time + type: string + created_by: + type: string + id: + format: int64 + type: integer + matched_person_id: + format: int64 + type: integer + person_id: + format: int64 + type: integer + raw_related_type: + type: string + raw_related_value: + type: string + reviewed_at: + format: date-time + type: string + reviewed_by: + type: string + source: + type: string + source_ref: + type: string + status: + type: string + updated_at: + format: date-time + type: string + value_kind: + type: string + vcard_identity: + $ref: "#/components/schemas/VCardIdentity" + required: + - id + - person_id + - raw_related_value + - raw_related_type + - value_kind + - status + - source + - vcard_identity + - created_by + - created_at + - updated_at + type: object + RelationshipReviewsResponse: + properties: + reviews: + items: + $ref: "#/components/schemas/RelationshipReview" + nullable: true + type: array + required: + - reviews + type: object RelationshipRow: properties: canonical_id: @@ -4514,6 +4758,71 @@ components: - cache_revision - identity_revision type: object + RelationshipType: + properties: + color: + type: string + created_at: + format: date-time + type: string + description: + type: string + forward_label: + type: string + icon: + type: string + id: + format: int64 + type: integer + inverse_type_id: + format: int64 + type: integer + is_canonical: + type: boolean + is_deletable: + type: boolean + is_symmetric: + type: boolean + ownership: + type: string + reverse_label: + type: string + revision: + format: int64 + type: integer + slug: + type: string + universal_id: + type: string + updated_at: + format: date-time + type: string + vcard_related_type: + type: string + required: + - id + - universal_id + - slug + - forward_label + - reverse_label + - is_symmetric + - is_canonical + - ownership + - is_deletable + - revision + - created_at + - updated_at + type: object + RelationshipTypesResponse: + properties: + relationship_types: + items: + $ref: "#/components/schemas/RelationshipType" + nullable: true + type: array + required: + - relationship_types + type: object RelationshipsHTTPRequest: additionalProperties: false properties: @@ -5990,7 +6299,7 @@ components: type: apiKey info: title: msgvault API - version: 1.38.0 + version: 1.39.0 openapi: 3.0.3 paths: /api/ping: @@ -10081,17 +10390,36 @@ paths: summary: Get one person's canonical activity timeline tags: - Exploration - /api/v1/persons: + /api/v1/person-relationship-reviews: get: - description: "Durable persons are curated profiles; /api/v1/people exposes derived analytics groupings. The listing is deliberately unpaginated: persons exist only through explicit promotion, so the set stays small." - operationId: listPersons + operationId: listPersonRelationshipReviews + parameters: + - in: query + name: status + schema: + enum: + - pending + - accepted + - rejected + type: string + - in: query + name: person_id + schema: + format: int64 + type: integer responses: "200": content: application/json: schema: - $ref: "#/components/schemas/PersonsResponse" + $ref: "#/components/schemas/RelationshipReviewsResponse" description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error "503": content: application/json: @@ -10106,41 +10434,46 @@ paths: description: Error security: - apiKey: [] - summary: List durable person profiles + summary: List imported RELATED values awaiting review tags: - API + /api/v1/person-relationships: post: - description: Returns 201 when a new person is created, or 200 when the cluster is already represented by a person (idempotent re-promotion, which also binds any unbound cluster members). - operationId: createPerson + operationId: createPersonRelationship requestBody: content: application/json: schema: - $ref: "#/components/schemas/CreatePersonRequest" + $ref: "#/components/schemas/CreatePersonRelationshipRequest" required: true responses: - "200": + "201": content: application/json: schema: - $ref: "#/components/schemas/Person" - description: OK + $ref: "#/components/schemas/PersonRelationship" + description: Created headers: ETag: - description: Strong person profile revision tag for optimistic concurrency + description: Strong person relationship revision tag for optimistic concurrency schema: type: string - "201": + Location: + description: Created person relationship + schema: + type: string + "400": content: application/json: schema: - $ref: "#/components/schemas/Person" - description: Created - headers: - ETag: - description: Strong person profile revision tag for optimistic concurrency + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: schema: - type: string + $ref: "#/components/schemas/ErrorResponse" + description: Error "409": content: application/json: @@ -10161,12 +10494,276 @@ paths: description: Error security: - apiKey: [] - summary: Promote a participant cluster to a durable person + summary: Declare a relationship between two persons tags: - API - /api/v1/persons/{id}: + /api/v1/person-relationships/{id}: delete: - description: "Deletion is permanent: the person's participant bindings are removed and its vCard UID is retired forever. Re-promoting the same cluster afterwards creates a new person with a new UID." + operationId: deletePersonRelationship + parameters: + - description: Person relationship ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Strong ETag returned by the latest person relationship read + in: header + name: If-Match + required: true + schema: + type: string + responses: + "204": + description: No Content + "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 + "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: Delete a person relationship + tags: + - API + get: + operationId: getPersonRelationship + parameters: + - description: Person relationship ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonRelationship" + description: OK + headers: + ETag: + description: Strong person relationship revision tag for optimistic concurrency + schema: + type: string + "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 one person relationship + tags: + - API + patch: + operationId: patchPersonRelationship + parameters: + - description: Person relationship ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Strong ETag returned by the latest person relationship read + in: header + name: If-Match + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PatchPersonRelationshipRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonRelationship" + description: OK + headers: + ETag: + description: Strong person relationship 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 + "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: End a relationship or replace its notes + tags: + - API + /api/v1/persons: + get: + description: "Durable persons are curated profiles; /api/v1/people exposes derived analytics groupings. The listing is deliberately unpaginated: persons exist only through explicit promotion, so the set stays small." + operationId: listPersons + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonsResponse" + description: OK + "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 durable person profiles + tags: + - API + post: + description: Returns 201 when a new person is created, or 200 when the cluster is already represented by a person (idempotent re-promotion, which also binds any unbound cluster members). + operationId: createPerson + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreatePersonRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Person" + description: OK + headers: + ETag: + description: Strong person profile revision tag for optimistic concurrency + schema: + type: string + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/Person" + description: Created + headers: + ETag: + description: Strong person profile revision tag for optimistic concurrency + schema: + type: string + "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: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Promote a participant cluster to a durable person + tags: + - API + /api/v1/persons/{id}: + delete: + description: "Deletion is permanent: the person's participant bindings are removed and its vCard UID is retired forever. Re-promoting the same cluster afterwards creates a new person with a new UID." operationId: deletePerson parameters: - description: Durable person ID @@ -10800,13 +11397,58 @@ paths: summary: Download stored inline content for one person profile media value tags: - API - /api/v1/query: - post: - operationId: runQuery - requestBody: - content: - application/json: - schema: + /api/v1/persons/{id}/relationships: + get: + operationId: listPersonRelationships + parameters: + - description: Durable person ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - in: query + name: include_ended + schema: + type: boolean + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonRelationshipsResponse" + 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 one person's relationships + tags: + - API + /api/v1/query: + post: + operationId: runQuery + requestBody: + content: + application/json: + schema: $ref: "#/components/schemas/QueryRequest" required: true responses: @@ -10827,6 +11469,267 @@ paths: summary: Run an aggregate query tags: - API + /api/v1/relationship-types: + get: + operationId: listRelationshipTypes + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/RelationshipTypesResponse" + description: OK + "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 person relationship types + tags: + - API + post: + operationId: createRelationshipType + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreateRelationshipTypeRequest" + required: true + responses: + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/RelationshipType" + description: Created + headers: + ETag: + description: Strong relationship type revision tag for optimistic concurrency + schema: + type: string + Location: + description: Created relationship type + schema: + type: string + "400": + 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: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Create a user-owned relationship type + tags: + - API + /api/v1/relationship-types/{id}: + delete: + operationId: deleteRelationshipType + parameters: + - description: Relationship type ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Strong ETag returned by the latest relationship type read + in: header + name: If-Match + required: true + schema: + type: string + responses: + "204": + description: No Content + "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 + "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: Delete an unused relationship type + tags: + - API + get: + operationId: getRelationshipType + parameters: + - description: Relationship type ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/RelationshipType" + description: OK + headers: + ETag: + description: Strong relationship type revision tag for optimistic concurrency + schema: + type: string + "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 relationship type + tags: + - API + patch: + operationId: patchRelationshipType + parameters: + - description: Relationship type ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Strong ETag returned by the latest relationship type read + in: header + name: If-Match + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PatchRelationshipTypeRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/RelationshipType" + description: OK + headers: + ETag: + description: Strong relationship type 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 + "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: Update a relationship type + tags: + - API /api/v1/relationships: post: operationId: listRelationships diff --git a/web/src/lib/api/generated/schema.d.ts b/web/src/lib/api/generated/schema.d.ts index bc3d3788d..50020e654 100644 --- a/web/src/lib/api/generated/schema.d.ts +++ b/web/src/lib/api/generated/schema.d.ts @@ -1417,6 +1417,59 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/person-relationship-reviews": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List imported RELATED values awaiting review */ + get: operations["listPersonRelationshipReviews"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/person-relationships": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Declare a relationship between two persons */ + post: operations["createPersonRelationship"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/person-relationships/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get one person relationship */ + get: operations["getPersonRelationship"]; + put?: never; + post?: never; + /** Delete a person relationship */ + delete: operations["deletePersonRelationship"]; + options?: never; + head?: never; + /** End a relationship or replace its notes */ + patch: operations["patchPersonRelationship"]; + trace?: never; + }; "/api/v1/persons": { parameters: { query?: never; @@ -1562,6 +1615,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/persons/{id}/relationships": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List one person's relationships */ + get: operations["listPersonRelationships"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/query": { parameters: { query?: never; @@ -1579,6 +1649,43 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/relationship-types": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List person relationship types */ + get: operations["listRelationshipTypes"]; + put?: never; + /** Create a user-owned relationship type */ + post: operations["createRelationshipType"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/relationship-types/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a relationship type */ + get: operations["getRelationshipType"]; + put?: never; + post?: never; + /** Delete an unused relationship type */ + delete: operations["deleteRelationshipType"]; + options?: never; + head?: never; + /** Update a relationship type */ + patch: operations["patchRelationshipType"]; + trace?: never; + }; "/api/v1/relationships": { parameters: { query?: never; @@ -2707,10 +2814,30 @@ export interface components { slug: string; uri_scheme?: string; }; + CreatePersonRelationshipRequest: { + end_date?: string; + notes?: string | null; + relationship_type_slug: string; + /** Format: int64 */ + source_person_id: number; + start_date?: string; + /** Format: int64 */ + target_person_id: number; + }; CreatePersonRequest: { /** Format: int64 */ participant_id: number; }; + CreateRelationshipTypeRequest: { + color?: string | null; + description?: string | null; + forward_label: string; + icon?: string | null; + is_symmetric?: boolean; + reverse_label: string; + slug: string; + vcard_related_type?: string | null; + }; CreateRequest: { accounts: string[] | null; name: string; @@ -3560,9 +3687,21 @@ export interface components { is_active?: boolean; label?: string; }; + PatchPersonRelationshipRequest: { + end_date?: string; + notes?: string | null; + }; PatchPersonRequest: { display_name: string | null; }; + PatchRelationshipTypeRequest: { + color?: string; + description?: string; + forward_label?: string; + icon?: string; + reverse_label?: string; + vcard_related_type?: string; + }; PatchSavedViewRequest: { canonical_state?: components["schemas"]["SavedViewStateEnvelope"]; description?: string; @@ -3889,6 +4028,55 @@ export interface components { media?: components["schemas"]["PersonMediaPatchRequest"]; names?: components["schemas"]["PersonNamePatchRequest"]; }; + PersonRelationship: { + /** Format: double */ + confidence?: number; + /** Format: date-time */ + created_at: string; + created_by: string; + end_date?: components["schemas"]["PartialDate"]; + forward_label: string; + /** Format: int64 */ + id: number; + is_symmetric: boolean; + notes?: string; + /** Format: int64 */ + relationship_type_id: number; + reverse_label: string; + /** Format: int64 */ + revision: number; + source: string; + /** Format: int64 */ + source_person_id: number; + source_ref?: string; + start_date?: components["schemas"]["PartialDate"]; + status: string; + /** Format: int64 */ + target_person_id: number; + type_slug: string; + /** Format: date-time */ + updated_at: string; + updated_by: string; + vcard_identity: components["schemas"]["VCardIdentity"]; + } & { + [key: string]: unknown; + }; + PersonRelationshipView: { + counterpart_display_name?: string; + counterpart_label: string; + /** Format: int64 */ + counterpart_person_id: number; + counterpart_vcard_uid: string; + direction: string; + relationship: components["schemas"]["PersonRelationship"]; + } & { + [key: string]: unknown; + }; + PersonRelationshipsResponse: { + relationships: components["schemas"]["PersonRelationshipView"][] | null; + } & { + [key: string]: unknown; + }; PersonSearchHTTPResponse: { cache_revision: string; candidate_snapshot_id?: string; @@ -3961,6 +4149,38 @@ export interface components { } & { [key: string]: unknown; }; + RelationshipReview: { + /** Format: int64 */ + accepted_relationship_id?: number; + /** Format: date-time */ + created_at: string; + created_by: string; + /** Format: int64 */ + id: number; + /** Format: int64 */ + matched_person_id?: number; + /** Format: int64 */ + person_id: number; + raw_related_type: string; + raw_related_value: string; + /** Format: date-time */ + reviewed_at?: string; + reviewed_by?: string; + source: string; + source_ref?: string; + status: string; + /** Format: date-time */ + updated_at: string; + value_kind: string; + vcard_identity: components["schemas"]["VCardIdentity"]; + } & { + [key: string]: unknown; + }; + RelationshipReviewsResponse: { + reviews: components["schemas"]["RelationshipReview"][] | null; + } & { + [key: string]: unknown; + }; RelationshipRow: { /** Format: int64 */ canonical_id: number; @@ -4012,6 +4232,37 @@ export interface components { } & { [key: string]: unknown; }; + RelationshipType: { + color?: string; + /** Format: date-time */ + created_at: string; + description?: string; + forward_label: string; + icon?: string; + /** Format: int64 */ + id: number; + /** Format: int64 */ + inverse_type_id?: number; + is_canonical: boolean; + is_deletable: boolean; + is_symmetric: boolean; + ownership: string; + reverse_label: string; + /** Format: int64 */ + revision: number; + slug: string; + universal_id: string; + /** Format: date-time */ + updated_at: string; + vcard_related_type?: string; + } & { + [key: string]: unknown; + }; + RelationshipTypesResponse: { + relationship_types: components["schemas"]["RelationshipType"][] | null; + } & { + [key: string]: unknown; + }; RelationshipsHTTPRequest: { cursor?: string; filters?: components["schemas"]["ExploreFilter"][] | null; @@ -9315,9 +9566,12 @@ export interface operations { }; }; }; - listPersons: { + listPersonRelationshipReviews: { parameters: { - query?: never; + query?: { + status?: "pending" | "accepted" | "rejected"; + person_id?: number; + }; header?: never; path?: never; cookie?: never; @@ -9330,7 +9584,16 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PersonsResponse"]; + "application/json": components["schemas"]["RelationshipReviewsResponse"]; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; }; }; /** @description Error */ @@ -9353,7 +9616,7 @@ export interface operations { }; }; }; - createPerson: { + createPersonRelationship: { parameters: { query?: never; header?: never; @@ -9362,30 +9625,39 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["CreatePersonRequest"]; + "application/json": components["schemas"]["CreatePersonRelationshipRequest"]; }; }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { - /** @description Strong person profile revision tag for optimistic concurrency */ + /** @description Strong person relationship revision tag for optimistic concurrency */ ETag?: string; + /** @description Created person relationship */ + Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Person"]; + "application/json": components["schemas"]["PersonRelationship"]; }; }; - /** @description Created */ - 201: { + /** @description Error */ + 400: { headers: { - /** @description Strong person profile revision tag for optimistic concurrency */ - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Person"]; + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; }; }; /** @description Error */ @@ -9417,12 +9689,12 @@ export interface operations { }; }; }; - getPersonProfile: { + getPersonRelationship: { parameters: { query?: never; header?: never; path: { - /** @description Durable person ID */ + /** @description Person relationship ID */ id: number; }; cookie?: never; @@ -9432,12 +9704,12 @@ export interface operations { /** @description OK */ 200: { headers: { - /** @description Strong person profile revision tag for optimistic concurrency */ + /** @description Strong person relationship revision tag for optimistic concurrency */ ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Person"]; + "application/json": components["schemas"]["PersonRelationship"]; }; }; /** @description Error */ @@ -9469,15 +9741,15 @@ export interface operations { }; }; }; - deletePerson: { + deletePersonRelationship: { 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. */ + /** @description Strong ETag returned by the latest person relationship read */ "If-Match": string; }; path: { - /** @description Durable person ID */ + /** @description Person relationship ID */ id: number; }; cookie?: never; @@ -9501,15 +9773,6 @@ export interface operations { }; }; /** @description Error */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ErrorResponse"]; - }; - }; - /** @description Error */ 404: { headers: { [name: string]: unknown; @@ -9537,15 +9800,6 @@ export interface operations { }; }; /** @description Error */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ErrorResponse"]; - }; - }; - /** @description Error */ 503: { headers: { [name: string]: unknown; @@ -9565,34 +9819,43 @@ export interface operations { }; }; }; - patchPerson: { + patchPersonRelationship: { 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. */ + /** @description Strong ETag returned by the latest person relationship read */ "If-Match": string; }; path: { - /** @description Durable person ID */ + /** @description Person relationship ID */ id: number; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["PatchPersonRequest"]; + "application/json": components["schemas"]["PatchPersonRelationshipRequest"]; }; }; responses: { /** @description OK */ 200: { headers: { - /** @description Strong person profile revision tag for optimistic concurrency */ + /** @description Strong person relationship revision tag for optimistic concurrency */ ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Person"]; + "application/json": components["schemas"]["PersonRelationship"]; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; }; }; /** @description Error */ @@ -9642,19 +9905,11 @@ export interface operations { }; }; }; - listPersonAttributes: { + listPersons: { parameters: { - query?: { - /** @description Include superseded values */ - history?: boolean; - /** @description Restrict the response to one definition slug */ - slug?: string; - }; + query?: never; header?: never; - path: { - /** @description Durable person ID */ - id: number; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -9665,11 +9920,11 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PersonAttributesResponse"]; + "application/json": components["schemas"]["PersonsResponse"]; }; }; /** @description Error */ - 404: { + 503: { headers: { [name: string]: unknown; }; @@ -9678,7 +9933,652 @@ export interface operations { }; }; /** @description Error */ - 503: { + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + createPerson: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreatePersonRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + /** @description Strong person profile revision tag for optimistic concurrency */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Person"]; + }; + }; + /** @description Created */ + 201: { + headers: { + /** @description Strong person profile revision tag for optimistic concurrency */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Person"]; + }; + }; + /** @description Error */ + 409: { + 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"]; + }; + }; + }; + }; + getPersonProfile: { + 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"]["Person"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + deletePerson: { + 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?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @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 */ + 409: { + 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 */ + 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"]; + }; + }; + }; + }; + patchPerson: { + 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"]["PatchPersonRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + /** @description Strong person profile revision tag for optimistic concurrency */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Person"]; + }; + }; + /** @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 */ + 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"]; + }; + }; + }; + }; + listPersonAttributes: { + parameters: { + query?: { + /** @description Include superseded values */ + history?: boolean; + /** @description Restrict the response to one definition slug */ + slug?: string; + }; + 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"]["PersonAttributesResponse"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + setPersonAttribute: { + parameters: { + query?: { + /** @description Validate and preview without writing */ + dry_run?: boolean; + }; + header?: never; + path: { + /** @description Durable person ID */ + id: number; + /** @description Immutable attribute definition slug */ + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetPersonAttributeRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonAttributeWrite"]; + }; + }; + /** @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 */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + clearPersonAttribute: { + parameters: { + query?: { + /** @description Ordinal for a multi-valued definition */ + ordinal?: number; + /** @description Compare-and-swap: the current value ID expected to be superseded */ + expected_value_id?: number; + /** @description Validate and preview without writing */ + dry_run?: boolean; + }; + header?: never; + path: { + /** @description Durable person ID */ + id: number; + /** @description Immutable attribute definition slug */ + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonAttributeWrite"]; + }; + }; + /** @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 */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + 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; }; @@ -9697,26 +10597,17 @@ export interface operations { }; }; }; - setPersonAttribute: { + getPersonProfileHistory: { parameters: { - query?: { - /** @description Validate and preview without writing */ - dry_run?: boolean; - }; + query?: never; header?: never; path: { /** @description Durable person ID */ id: number; - /** @description Immutable attribute definition slug */ - slug: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["SetPersonAttributeRequest"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -9724,7 +10615,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PersonAttributeWrite"]; + "application/json": components["schemas"]["PersonProfileHistory"]; }; }; /** @description Error */ @@ -9746,15 +10637,6 @@ export interface operations { }; }; /** @description Error */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ErrorResponse"]; - }; - }; - /** @description Error */ 503: { headers: { [name: string]: unknown; @@ -9774,22 +10656,15 @@ export interface operations { }; }; }; - clearPersonAttribute: { + getPersonProfileMediaContent: { parameters: { - query?: { - /** @description Ordinal for a multi-valued definition */ - ordinal?: number; - /** @description Compare-and-swap: the current value ID expected to be superseded */ - expected_value_id?: number; - /** @description Validate and preview without writing */ - dry_run?: boolean; - }; + query?: never; header?: never; path: { /** @description Durable person ID */ id: number; - /** @description Immutable attribute definition slug */ - slug: string; + /** @description Structured person profile media value ID */ + media_id: number; }; cookie?: never; }; @@ -9801,7 +10676,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PersonAttributeWrite"]; + "*/*": string; }; }; /** @description Error */ @@ -9814,6 +10689,15 @@ export interface operations { }; }; /** @description Error */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ 404: { headers: { [name: string]: unknown; @@ -9823,7 +10707,7 @@ export interface operations { }; }; /** @description Error */ - 409: { + 500: { headers: { [name: string]: unknown; }; @@ -9851,9 +10735,11 @@ export interface operations { }; }; }; - getPersonStructuredProfile: { + listPersonRelationships: { parameters: { - query?: never; + query?: { + include_ended?: boolean; + }; header?: never; path: { /** @description Durable person ID */ @@ -9866,12 +10752,10 @@ export interface operations { /** @description OK */ 200: { headers: { - /** @description Strong person profile revision tag for optimistic concurrency */ - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["StructuredPersonProfile"]; + "application/json": components["schemas"]["PersonRelationshipsResponse"]; }; }; /** @description Error */ @@ -9884,7 +10768,7 @@ export interface operations { }; }; /** @description Error */ - 404: { + 503: { headers: { [name: string]: unknown; }; @@ -9893,7 +10777,7 @@ export interface operations { }; }; /** @description Error */ - 503: { + default: { headers: { [name: string]: unknown; }; @@ -9901,6 +10785,30 @@ export interface operations { "application/json": components["schemas"]["ErrorResponse"]; }; }; + }; + }; + runQuery: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["QueryRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["QueryResult"]; + }; + }; /** @description Error */ default: { headers: { @@ -9912,38 +10820,26 @@ export interface operations { }; }; }; - patchPersonStructuredProfile: { + listRelationshipTypes: { 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; - }; + header?: never; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["PersonProfilePatchRequest"]; - }; - }; + 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"]; + "application/json": components["schemas"]["RelationshipTypesResponse"]; }; }; /** @description Error */ - 400: { + 503: { headers: { [name: string]: unknown; }; @@ -9952,7 +10848,7 @@ export interface operations { }; }; /** @description Error */ - 404: { + default: { headers: { [name: string]: unknown; }; @@ -9960,17 +10856,36 @@ export interface operations { "application/json": components["schemas"]["ErrorResponse"]; }; }; - /** @description Error */ - 409: { + }; + }; + createRelationshipType: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateRelationshipTypeRequest"]; + }; + }; + responses: { + /** @description Created */ + 201: { headers: { + /** @description Strong relationship type revision tag for optimistic concurrency */ + ETag?: string; + /** @description Created relationship type */ + Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ErrorResponse"]; + "application/json": components["schemas"]["RelationshipType"]; }; }; /** @description Error */ - 413: { + 400: { headers: { [name: string]: unknown; }; @@ -9979,7 +10894,7 @@ export interface operations { }; }; /** @description Error */ - 428: { + 409: { headers: { [name: string]: unknown; }; @@ -10007,12 +10922,12 @@ export interface operations { }; }; }; - getPersonProfileHistory: { + getRelationshipType: { parameters: { query?: never; header?: never; path: { - /** @description Durable person ID */ + /** @description Relationship type ID */ id: number; }; cookie?: never; @@ -10022,19 +10937,12 @@ export interface operations { /** @description OK */ 200: { headers: { + /** @description Strong relationship type revision tag for optimistic concurrency */ + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PersonProfileHistory"]; - }; - }; - /** @description Error */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ErrorResponse"]; + "application/json": components["schemas"]["RelationshipType"]; }; }; /** @description Error */ @@ -10066,28 +10974,27 @@ export interface operations { }; }; }; - getPersonProfileMediaContent: { + deleteRelationshipType: { parameters: { query?: never; - header?: never; + header: { + /** @description Strong ETag returned by the latest relationship type read */ + "If-Match": string; + }; path: { - /** @description Durable person ID */ + /** @description Relationship type ID */ id: number; - /** @description Structured person profile media value ID */ - media_id: number; }; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; - content: { - "*/*": string; - }; + content?: never; }; /** @description Error */ 400: { @@ -10099,7 +11006,7 @@ export interface operations { }; }; /** @description Error */ - 401: { + 404: { headers: { [name: string]: unknown; }; @@ -10108,7 +11015,7 @@ export interface operations { }; }; /** @description Error */ - 404: { + 409: { headers: { [name: string]: unknown; }; @@ -10117,7 +11024,7 @@ export interface operations { }; }; /** @description Error */ - 500: { + 428: { headers: { [name: string]: unknown; }; @@ -10145,26 +11052,79 @@ export interface operations { }; }; }; - runQuery: { + patchRelationshipType: { parameters: { query?: never; - header?: never; - path?: never; + header: { + /** @description Strong ETag returned by the latest relationship type read */ + "If-Match": string; + }; + path: { + /** @description Relationship type ID */ + id: number; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["QueryRequest"]; + "application/json": components["schemas"]["PatchRelationshipTypeRequest"]; }; }; responses: { /** @description OK */ 200: { headers: { + /** @description Strong relationship type revision tag for optimistic concurrency */ + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["QueryResult"]; + "application/json": components["schemas"]["RelationshipType"]; + }; + }; + /** @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 */ + 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 */ From 775ae52e382f7f3d8a2a9ef81f5c3ad08edd6112 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 12 Aug 2026 16:51:33 -0500 Subject: [PATCH 2/4] fix(people): repair symmetry drift safely and orient RELATED imports Restoring a seeded type's is_symmetric flag now also restores the seed labels when an out-of-band writer pulled them apart, so reconciliation can no longer fail the symmetric-label CHECK and abort InitSchema. Label updates on seeded types validate against seed-defined symmetry in addition to the stored flag, closing the API window that accepted unequal labels on a drifted row. RELATED imports now store the matched person as the typed source: the TYPE parameter describes the related person's role toward the card subject (RFC 6350), so TYPE=parent naming bob on alice's card records bob as alice's parent. Review acceptance materializes the same orientation. Co-Authored-By: Claude Fable 5 --- internal/store/person_relationship_related.go | 21 +++++--- .../store/person_relationship_related_test.go | 54 +++++++++++++++++-- internal/store/relationship_type_seed.go | 35 +++++++++++- internal/store/relationship_types.go | 9 +++- internal/store/relationship_types_test.go | 33 ++++++++++++ 5 files changed, 139 insertions(+), 13 deletions(-) diff --git a/internal/store/person_relationship_related.go b/internal/store/person_relationship_related.go index cdc23840f..6d64b4048 100644 --- a/internal/store/person_relationship_related.go +++ b/internal/store/person_relationship_related.go @@ -160,8 +160,12 @@ func (s *Store) ResolveRelatedValueContext(ctx context.Context, in RelatedImport return nil, err } if personMatched && typeMatched { + // RELATED's TYPE describes the related person's role toward the card + // subject (RFC 6350 section 6.6.6): TYPE=parent naming bob on alice's + // card asserts that bob is alice's parent. The matched person is + // therefore the typed source and the card subject the target. edge, err := s.AddPersonRelationshipContext(ctx, PersonRelationshipInput{ - SourcePersonID: in.PersonID, TargetPersonID: matchedPersonID, + SourcePersonID: matchedPersonID, TargetPersonID: in.PersonID, TypeSlug: relationshipType.Slug, Source: in.Source, SourceRef: in.SourceRef, VCardIdentity: in.VCardIdentity, Actor: actor, }) @@ -171,7 +175,7 @@ func (s *Store) ResolveRelatedValueContext(ctx context.Context, in RelatedImport if !errors.Is(err, ErrPersonRelationshipDuplicate) { return nil, err } - existing, findErr := s.activePersonRelationshipContext(ctx, in.PersonID, matchedPersonID, relationshipType.Slug) + existing, findErr := s.activePersonRelationshipContext(ctx, matchedPersonID, in.PersonID, relationshipType.Slug) if findErr != nil { return nil, findErr } @@ -324,13 +328,18 @@ func (s *Store) ListRelationshipReviewsContext(ctx context.Context, opts Relatio // AcceptRelationshipReviewContext claims the pending row, writes its edge, // and records that edge ID in one transaction. Any edge failure rolls the // conditional claim back, so review and edge can never diverge. -func (s *Store) AcceptRelationshipReviewContext(ctx context.Context, id int64, typeSlug string, targetPersonID int64, actor string) (*PersonRelationship, error) { +// +// relatedPersonID is the person the reviewer resolved the RELATED value to. +// Like automatic resolution, acceptance materializes the card subject's +// assertion "this person is my ", so the related person is the typed +// source and the review's subject the target. +func (s *Store) AcceptRelationshipReviewContext(ctx context.Context, id int64, typeSlug string, relatedPersonID int64, actor string) (*PersonRelationship, error) { trimmedActor, err := validateRelationshipActor(actor) if err != nil { return nil, fmt.Errorf("%w: %w", ErrRelationshipReviewInvalid, err) } - if targetPersonID <= 0 { - return nil, fmt.Errorf("%w: target person ID must be positive", ErrRelationshipReviewInvalid) + if relatedPersonID <= 0 { + return nil, fmt.Errorf("%w: related person ID must be positive", ErrRelationshipReviewInvalid) } var edge *PersonRelationship err = s.withTxContext(ctx, func(tx *loggedTx) error { @@ -338,7 +347,7 @@ func (s *Store) AcceptRelationshipReviewContext(ctx context.Context, id int64, t if txErr != nil { return txErr } - input := PersonRelationshipInput{SourcePersonID: review.PersonID, TargetPersonID: targetPersonID, TypeSlug: typeSlug, Source: review.Source, SourceRef: review.SourceRef, VCardIdentity: review.VCardIdentity, Actor: trimmedActor} + input := PersonRelationshipInput{SourcePersonID: relatedPersonID, TargetPersonID: review.PersonID, TypeSlug: typeSlug, Source: review.Source, SourceRef: review.SourceRef, VCardIdentity: review.VCardIdentity, Actor: trimmedActor} validatedActor, notes, txErr := validatePersonRelationshipInput(input) if txErr != nil { return txErr diff --git a/internal/store/person_relationship_related_test.go b/internal/store/person_relationship_related_test.go index 3087c4902..dd010b9b2 100644 --- a/internal/store/person_relationship_related_test.go +++ b/internal/store/person_relationship_related_test.go @@ -47,13 +47,13 @@ func TestResolveRelatedValueLinksExactUIDAndReturnsExactCanonicalDuplicate(t *te require.NoError(err) require.NotNil(first.Relationship) assert.Equal("agent", first.Relationship.TypeSlug) - assert.Equal(aliceID, first.Relationship.SourcePersonID) - assert.Equal(bobID, first.Relationship.TargetPersonID) + assert.Equal(bobID, first.Relationship.SourcePersonID, "TYPE=agent on alice's card makes bob the agent") + assert.Equal(aliceID, first.Relationship.TargetPersonID) // agent is asymmetric, so its reciprocal edge is distinct. Re-importing // alice's assertion must find the first exact canonical triple, not search - // both orientations and accidentally return Bob's reciprocal assertion. - _, err = f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{SourcePersonID: bobID, TargetPersonID: aliceID, TypeSlug: "agent", Source: store.ProvenanceUser, Actor: "user"}) + // both orientations and accidentally return the reciprocal edge. + _, err = f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{SourcePersonID: aliceID, TargetPersonID: bobID, TypeSlug: "agent", Source: store.ProvenanceUser, Actor: "user"}) require.NoError(err) again, err := f.Store.ResolveRelatedValueContext(ctx, in) require.NoError(err) @@ -369,3 +369,49 @@ func TestRelationshipReviewDatabaseConstraintsAndColumns(t *testing.T) { require.NoError(rows.Err()) assert.ElementsMatch([]string{"id", "person_id", "raw_related_value", "raw_related_type", "value_kind", "matched_person_id", "status", "accepted_relationship_id", "source", "source_ref", "vcard_property", "vcard_group", "vcard_prop_id", "vcard_pid", "vcard_altid", "created_by", "reviewed_by", "reviewed_at", "created_at", "updated_at"}, columns) } + +func TestResolveRelatedValueOrientsAsymmetricTypeFromTheRelatedPerson(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + aliceID, bobID := mustTwoPersons(t, f) + bob, err := f.Store.GetPerson(bobID) + require.NoError(err) + + // RELATED;TYPE=parent on alice's card naming bob asserts that bob is + // alice's parent: the TYPE value describes the related person's role + // toward the card subject (RFC 6350 section 6.6.6). + resolution, err := f.Store.ResolveRelatedValueContext(ctx, store.RelatedImport{ + PersonID: aliceID, RawValue: bob.VCardUID, RawType: "parent", + ValueKind: store.RelatedValueKindText, Source: store.ProvenanceVCardImport, Actor: "system", + }) + require.NoError(err) + require.NotNil(resolution.Relationship) + assert.Equal("parent", resolution.Relationship.TypeSlug) + assert.Equal(bobID, resolution.Relationship.SourcePersonID) + assert.Equal(aliceID, resolution.Relationship.TargetPersonID) +} + +func TestAcceptRelationshipReviewOrientsEdgeFromTheRelatedPerson(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + aliceID, bobID := mustTwoPersons(t, f) + + staged, err := f.Store.ResolveRelatedValueContext(ctx, store.RelatedImport{ + PersonID: aliceID, RawValue: "Bob from the gym", RawType: "agent", + ValueKind: store.RelatedValueKindText, Source: store.ProvenanceVCardImport, Actor: "system", + }) + require.NoError(err) + require.NotNil(staged.Review) + + // Accepting resolves the card subject's assertion "this person is my + // agent", so bob is the typed source and alice the target. + edge, err := f.Store.AcceptRelationshipReviewContext(ctx, staged.Review.ID, "agent", bobID, "user") + require.NoError(err) + assert.Equal("agent", edge.TypeSlug) + assert.Equal(bobID, edge.SourcePersonID) + assert.Equal(aliceID, edge.TargetPersonID) +} diff --git a/internal/store/relationship_type_seed.go b/internal/store/relationship_type_seed.go index 1714bd8c5..6e4a8aae5 100644 --- a/internal/store/relationship_type_seed.go +++ b/internal/store/relationship_type_seed.go @@ -99,7 +99,11 @@ var systemRelationshipTypes = []systemRelationshipTypeSeed{ // // forward_label and reverse_label are user-owned because relabelling is the // point of having a mutable label; a user who renames "friend" to "mate" keeps -// that name across every future upgrade. +// that name across every future upgrade. The one exception: restoring +// is_symmetric on a drifted symmetric seed whose labels were pulled apart by a +// writer that bypassed Go also restores the seed labels, because unequal +// labels would fail the symmetric-label CHECK and take InitSchema down with +// them. // // vcard_related_type is also user-owned. A user may clear a seeded mapping and // assign that registered RELATED TYPE to another type. Re-seeding must preserve @@ -189,6 +193,14 @@ func (s *Store) reconcileSeededRelationshipTypeTx( if !seededRelationshipTypeDiffers(current, seed) { return false, nil } + forward, reverse := current.ForwardLabel, current.ReverseLabel + if seed.IsSymmetric && forward != reverse { + // Labels are user-owned and normally never rewritten, but a symmetric + // type cannot keep unequal labels: writing is_symmetric back below + // would fail the symmetric-label CHECK and abort InitSchema. The seed + // labels are the only deterministic repair. + forward, reverse = seed.ForwardLabel, seed.ReverseLabel + } if !seed.IsCanonical { var inverseTypeID int64 if err := tx.QueryRowContext(ctx, @@ -200,11 +212,13 @@ func (s *Store) reconcileSeededRelationshipTypeTx( if _, err := tx.ExecContext(ctx, fmt.Sprintf(` UPDATE relationship_types SET is_symmetric = ?, is_canonical = ?, inverse_type_id = ?, + forward_label = ?, reverse_label = ?, ownership = ?, is_deletable = FALSE, revision = revision + 1, updated_at = %s WHERE universal_id = ? `, s.dialect.Now()), seed.IsSymmetric, seed.IsCanonical, inverseTypeID, + forward, reverse, string(RelationshipTypeOwnershipSystem), seed.UniversalID, ); err != nil { return false, fmt.Errorf("repair seeded relationship type %q: %w", seed.Slug, err) @@ -214,11 +228,13 @@ func (s *Store) reconcileSeededRelationshipTypeTx( if _, err := tx.ExecContext(ctx, fmt.Sprintf(` UPDATE relationship_types SET is_symmetric = ?, is_canonical = ?, + forward_label = ?, reverse_label = ?, ownership = ?, is_deletable = FALSE, revision = revision + 1, updated_at = %s WHERE universal_id = ? `, s.dialect.Now()), seed.IsSymmetric, seed.IsCanonical, + forward, reverse, string(RelationshipTypeOwnershipSystem), seed.UniversalID, ); err != nil { return false, fmt.Errorf("repair seeded relationship type %q: %w", seed.Slug, err) @@ -260,9 +276,24 @@ func (s *Store) reconcileSeededInverseTypeTx( return err } +// seededRelationshipTypeSymmetry returns the seed-defined symmetry for the +// seeded type with this universal ID, and whether the ID belongs to a seed at +// all. Label validation consults it so a stored is_symmetric flag that drifted +// out-of-band cannot let in labels that the next reconciliation would have to +// clobber. +func seededRelationshipTypeSymmetry(universalID string) (isSymmetric, isSeeded bool) { + for _, seed := range systemRelationshipTypes { + if seed.UniversalID == universalID { + return seed.IsSymmetric, true + } + } + return false, false +} + // seededRelationshipTypeDiffers reports whether any reconciled column has // drifted from the seed. It deliberately ignores slug, labels, vCard mapping, -// colour, icon, and description, which the seed never rewrites. +// colour, icon, and description, which reconciliation leaves alone except for +// the symmetric-label repair in reconcileSeededRelationshipTypeTx. func seededRelationshipTypeDiffers( current *RelationshipType, seed systemRelationshipTypeSeed, ) bool { diff --git a/internal/store/relationship_types.go b/internal/store/relationship_types.go index 38f6a9cea..c9feff8b1 100644 --- a/internal/store/relationship_types.go +++ b/internal/store/relationship_types.go @@ -367,7 +367,14 @@ func (s *Store) UpdateRelationshipTypeContext( return err } } - if current.IsSymmetric && forward != reverse { + // The stored flag backs the database CHECK; the seed-defined flag is + // what reconciliation restores on the next InitSchema. Either one + // makes unequal labels unacceptable. + symmetric := current.IsSymmetric + if seedSymmetric, seeded := seededRelationshipTypeSymmetry(current.UniversalID); seeded { + symmetric = symmetric || seedSymmetric + } + if symmetric && forward != reverse { return fmt.Errorf("%w: %q != %q", ErrRelationshipTypeSymmetricLabels, forward, reverse) } diff --git a/internal/store/relationship_types_test.go b/internal/store/relationship_types_test.go index bd62f2d2a..a4962fa97 100644 --- a/internal/store/relationship_types_test.go +++ b/internal/store/relationship_types_test.go @@ -531,3 +531,36 @@ func TestDeleteRelationshipTypeProtectsSystemTypesAndTypesInUse(t *testing.T) { _, err = f.Store.GetRelationshipTypeContext(ctx, unused.ID) require.ErrorIs(err, store.ErrRelationshipTypeNotFound) } + +func TestSeededSymmetryDriftRepairsLabelsInsteadOfBreakingInitSchema(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + + // A writer that bypasses Go flips a symmetric seed's flag. The update + // API must still refuse unequal labels, because the next reconciliation + // restores seed-defined symmetry regardless of the stored flag. + _, err := f.Store.DB().Exec(f.Store.Rebind( + `UPDATE relationship_types SET is_symmetric = FALSE WHERE slug = 'friend'`)) + require.NoError(err) + friend, err := f.Store.GetRelationshipTypeBySlugContext(ctx, "friend") + require.NoError(err) + mate, buddy := "mate", "buddy" + _, err = f.Store.UpdateRelationshipTypeContext(ctx, friend.ID, friend.Revision, + store.RelationshipTypeUpdate{ForwardLabel: &mate, ReverseLabel: &buddy}) + require.ErrorIs(err, store.ErrRelationshipTypeSymmetricLabels) + + // The same out-of-band writer also pulls the labels apart. Restoring + // is_symmetric must repair the labels too, not fail InitSchema on the + // symmetric-label CHECK. + _, err = f.Store.DB().Exec(f.Store.Rebind( + `UPDATE relationship_types SET forward_label = 'mate', reverse_label = 'buddy' WHERE slug = 'friend'`)) + require.NoError(err) + require.NoError(f.Store.EnsureSeededRelationshipTypesContext(ctx)) + repaired, err := f.Store.GetRelationshipTypeBySlugContext(ctx, "friend") + require.NoError(err) + assert.True(repaired.IsSymmetric) + assert.Equal("friend", repaired.ForwardLabel) + assert.Equal("friend", repaired.ReverseLabel) +} From f0c1438bfed7c8fd0b5b13db247ba4b2cd229953 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 12 Aug 2026 20:15:49 -0500 Subject: [PATCH 3/4] fix(people): honor prior review decisions on RELATED re-import Automatic resolution now looks up the occurrence's staged review before linking. A rejected occurrence never creates an edge, a pending review is accepted atomically with the edge that satisfies it, and an accepted review whose edge was deleted keeps that deletion instead of resurrecting the relationship on the next import. Co-Authored-By: Claude Fable 5 --- internal/store/person_relationship_related.go | 145 ++++++++++++------ .../store/person_relationship_related_test.go | 91 +++++++++++ 2 files changed, 193 insertions(+), 43 deletions(-) diff --git a/internal/store/person_relationship_related.go b/internal/store/person_relationship_related.go index 6d64b4048..0979a04ba 100644 --- a/internal/store/person_relationship_related.go +++ b/internal/store/person_relationship_related.go @@ -123,6 +123,11 @@ func (s *Store) ResolvePersonByVCardUIDContext(ctx context.Context, uid string) // ResolveRelatedValueContext automatically links only an exact UID plus a // recognized relationship type. All other inputs remain durable reviews. +// +// An occurrence that was already reviewed keeps its decision: a rejection +// never links on re-import, a pending review is accepted together with the +// new edge, and an accepted review whose edge was deleted since is not +// resurrected. func (s *Store) ResolveRelatedValueContext(ctx context.Context, in RelatedImport) (*RelatedResolution, error) { source, err := ParseProvenance(string(in.Source)) if err != nil { @@ -160,26 +165,7 @@ func (s *Store) ResolveRelatedValueContext(ctx context.Context, in RelatedImport return nil, err } if personMatched && typeMatched { - // RELATED's TYPE describes the related person's role toward the card - // subject (RFC 6350 section 6.6.6): TYPE=parent naming bob on alice's - // card asserts that bob is alice's parent. The matched person is - // therefore the typed source and the card subject the target. - edge, err := s.AddPersonRelationshipContext(ctx, PersonRelationshipInput{ - SourcePersonID: matchedPersonID, TargetPersonID: in.PersonID, - TypeSlug: relationshipType.Slug, Source: in.Source, SourceRef: in.SourceRef, - VCardIdentity: in.VCardIdentity, Actor: actor, - }) - if err == nil { - return &RelatedResolution{Relationship: edge}, nil - } - if !errors.Is(err, ErrPersonRelationshipDuplicate) { - return nil, err - } - existing, findErr := s.activePersonRelationshipContext(ctx, matchedPersonID, in.PersonID, relationshipType.Slug) - if findErr != nil { - return nil, findErr - } - return &RelatedResolution{Relationship: existing}, nil + return s.resolveMatchedRelatedValue(ctx, in, matchedPersonID, relationshipType, actor) } review, err := s.stageRelationshipReview(ctx, in, matchedPersonID, personMatched, actor) @@ -228,32 +214,100 @@ func (s *Store) relationshipTypeForRelatedType(ctx context.Context, rawType stri return *relationshipType, true, nil } -// activePersonRelationshipContext looks up exactly the canonical triple that -// AddPersonRelationshipContext would write. It never searches both endpoint -// orientations, because reciprocal asymmetric edges may coexist. -func (s *Store) activePersonRelationshipContext(ctx context.Context, sourceID, targetID int64, typeSlug string) (*PersonRelationship, error) { - var edge *PersonRelationship +// resolveMatchedRelatedValue links an exactly matched occurrence in one +// transaction, honoring any earlier review of the same occurrence: a settled +// decision stands, and a pending review is accepted alongside the edge that +// satisfies it. +func (s *Store) resolveMatchedRelatedValue( + ctx context.Context, in RelatedImport, matchedPersonID int64, + relationshipType RelationshipType, actor string, +) (*RelatedResolution, error) { + var resolution *RelatedResolution err := s.withTxContext(ctx, func(tx *loggedTx) error { - relationshipType, canonicalSource, canonicalTarget, err := s.canonicalRelationshipEndpointsTx(ctx, tx, sourceID, targetID, typeSlug) + review, err := s.relationshipReviewByOccurrenceTx(ctx, tx, in) + if err != nil && !errors.Is(err, ErrRelationshipReviewNotFound) { + return err + } + if review != nil && review.Status != RelationshipReviewPending { + resolution, err = s.settledRelatedDecisionTx(ctx, tx, review) + return err + } + // RELATED's TYPE describes the related person's role toward the card + // subject (RFC 6350 section 6.6.6): TYPE=parent naming bob on alice's + // card asserts that bob is alice's parent. The matched person is + // therefore the typed source and the card subject the target. + input := PersonRelationshipInput{ + SourcePersonID: matchedPersonID, TargetPersonID: in.PersonID, + TypeSlug: relationshipType.Slug, Source: in.Source, SourceRef: in.SourceRef, + VCardIdentity: in.VCardIdentity, Actor: actor, + } + validatedActor, notes, err := validatePersonRelationshipInput(input) if err != nil { return err } - edge, err = scanPersonRelationship(tx.QueryRowContext(ctx, - `SELECT `+personRelationshipColumns+personRelationshipFrom+` - WHERE r.source_person_id = ? AND r.target_person_id = ? - AND r.relationship_type_id = ? AND r.end_year IS NULL`, - canonicalSource, canonicalTarget, relationshipType.ID)) - if errors.Is(err, sql.ErrNoRows) { - return ErrPersonRelationshipNotFound + edge, err := s.addPersonRelationshipTx(ctx, tx, input, validatedActor, notes) + if errors.Is(err, ErrPersonRelationshipDuplicate) { + edge, err = s.activePersonRelationshipTx(ctx, tx, matchedPersonID, in.PersonID, relationshipType.Slug) } if err != nil { - return fmt.Errorf("find active relationship: %w", err) + return err } - return nil + resolution = &RelatedResolution{Relationship: edge} + if review == nil { + return nil + } + if _, err := s.claimRelationshipReviewTx(ctx, tx, review.ID, RelationshipReviewAccepted, validatedActor); err != nil { + return err + } + if err := s.recordAcceptedRelationshipTx(ctx, tx, review.ID, edge.ID); err != nil { + return err + } + resolution.Review, err = s.relationshipReviewTx(ctx, tx, review.ID) + return err }) if err != nil { return nil, err } + return resolution, nil +} + +// settledRelatedDecisionTx returns the standing decision for an occurrence +// that was already reviewed. A rejected occurrence never links, and an +// accepted one returns its recorded edge; when that edge was deleted since +// (accepted_relationship_id is ON DELETE SET NULL), re-import must not +// resurrect it. +func (s *Store) settledRelatedDecisionTx( + ctx context.Context, tx *loggedTx, review *RelationshipReview, +) (*RelatedResolution, error) { + if review.Status == RelationshipReviewAccepted && review.AcceptedRelationshipID != nil { + edge, err := s.personRelationshipTx(ctx, tx, *review.AcceptedRelationshipID) + if err != nil { + return nil, err + } + return &RelatedResolution{Relationship: edge, Review: review}, nil + } + return &RelatedResolution{Review: review}, nil +} + +// activePersonRelationshipTx looks up exactly the canonical triple that +// addPersonRelationshipTx would write. It never searches both endpoint +// orientations, because reciprocal asymmetric edges may coexist. +func (s *Store) activePersonRelationshipTx(ctx context.Context, tx *loggedTx, sourceID, targetID int64, typeSlug string) (*PersonRelationship, error) { + relationshipType, canonicalSource, canonicalTarget, err := s.canonicalRelationshipEndpointsTx(ctx, tx, sourceID, targetID, typeSlug) + if err != nil { + return nil, err + } + edge, err := scanPersonRelationship(tx.QueryRowContext(ctx, + `SELECT `+personRelationshipColumns+personRelationshipFrom+` + WHERE r.source_person_id = ? AND r.target_person_id = ? + AND r.relationship_type_id = ? AND r.end_year IS NULL`, + canonicalSource, canonicalTarget, relationshipType.ID)) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrPersonRelationshipNotFound + } + if err != nil { + return nil, fmt.Errorf("find active relationship: %w", err) + } return edge, nil } @@ -356,14 +410,7 @@ func (s *Store) AcceptRelationshipReviewContext(ctx context.Context, id int64, t if txErr != nil { return txErr } - _, txErr = tx.ExecContext(ctx, fmt.Sprintf(` - UPDATE person_relationship_reviews - SET accepted_relationship_id = ?, updated_at = %s - WHERE id = ?`, s.dialect.Now()), edge.ID, id) - if txErr != nil { - return fmt.Errorf("record accepted relationship for review %d: %w", id, txErr) - } - return nil + return s.recordAcceptedRelationshipTx(ctx, tx, id, edge.ID) }) if err != nil { return nil, err @@ -389,6 +436,18 @@ func (s *Store) RejectRelationshipReviewContext(ctx context.Context, id int64, a return rejected, nil } +// recordAcceptedRelationshipTx points an accepted review at the edge that +// satisfied it. +func (s *Store) recordAcceptedRelationshipTx(ctx context.Context, tx *loggedTx, reviewID, edgeID int64) error { + if _, err := tx.ExecContext(ctx, fmt.Sprintf(` + UPDATE person_relationship_reviews + SET accepted_relationship_id = ?, updated_at = %s + WHERE id = ?`, s.dialect.Now()), edgeID, reviewID); err != nil { + return fmt.Errorf("record accepted relationship for review %d: %w", reviewID, err) + } + return nil +} + func (s *Store) claimRelationshipReviewTx(ctx context.Context, tx *loggedTx, id int64, status RelationshipReviewStatus, actor string) (*RelationshipReview, error) { var updatedID int64 err := tx.QueryRowContext(ctx, fmt.Sprintf(` diff --git a/internal/store/person_relationship_related_test.go b/internal/store/person_relationship_related_test.go index dd010b9b2..97c0ea42c 100644 --- a/internal/store/person_relationship_related_test.go +++ b/internal/store/person_relationship_related_test.go @@ -415,3 +415,94 @@ func TestAcceptRelationshipReviewOrientsEdgeFromTheRelatedPerson(t *testing.T) { assert.Equal(bobID, edge.SourcePersonID) assert.Equal(aliceID, edge.TargetPersonID) } + +func TestResolveRelatedValuePreservesRejectionWhenOccurrenceBecomesResolvable(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + aliceID, bobID := mustTwoPersons(t, f) + bob, err := f.Store.GetPerson(bobID) + require.NoError(err) + + // "me" is registered but unseeded, so the occurrence stages a review. + in := store.RelatedImport{PersonID: aliceID, RawValue: bob.VCardUID, RawType: "me", + ValueKind: store.RelatedValueKindText, Source: store.ProvenanceVCardImport, Actor: "system"} + staged, err := f.Store.ResolveRelatedValueContext(ctx, in) + require.NoError(err) + require.NotNil(staged.Review) + _, err = f.Store.RejectRelationshipReviewContext(ctx, staged.Review.ID, "user") + require.NoError(err) + + // The mapping appears later; the human's rejection must still stand. + me := "me" + _, err = f.Store.CreateRelationshipTypeContext(ctx, store.RelationshipTypeInput{ + Slug: "same-person", ForwardLabel: "same person", ReverseLabel: "same person", + IsSymmetric: true, VCardRelatedType: &me, + }) + require.NoError(err) + again, err := f.Store.ResolveRelatedValueContext(ctx, in) + require.NoError(err) + assert.Nil(again.Relationship) + require.NotNil(again.Review) + assert.Equal(staged.Review.ID, again.Review.ID) + assert.Equal(store.RelationshipReviewRejected, again.Review.Status) + views, err := f.Store.ListPersonRelationshipsContext(ctx, aliceID, store.PersonRelationshipListOptions{}) + require.NoError(err) + assert.Empty(views, "a rejected occurrence must never auto-link") +} + +func TestResolveRelatedValueAcceptsPendingReviewWhenOccurrenceBecomesResolvable(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + aliceID, bobID := mustTwoPersons(t, f) + bob, err := f.Store.GetPerson(bobID) + require.NoError(err) + + in := store.RelatedImport{PersonID: aliceID, RawValue: bob.VCardUID, RawType: "me", + ValueKind: store.RelatedValueKindText, Source: store.ProvenanceVCardImport, Actor: "system"} + staged, err := f.Store.ResolveRelatedValueContext(ctx, in) + require.NoError(err) + require.NotNil(staged.Review) + + me := "me" + _, err = f.Store.CreateRelationshipTypeContext(ctx, store.RelationshipTypeInput{ + Slug: "same-person", ForwardLabel: "same person", ReverseLabel: "same person", + IsSymmetric: true, VCardRelatedType: &me, + }) + require.NoError(err) + + // Re-import links the edge and settles the pending review in one step. + resolved, err := f.Store.ResolveRelatedValueContext(ctx, in) + require.NoError(err) + require.NotNil(resolved.Relationship) + require.NotNil(resolved.Review) + assert.Equal(store.RelationshipReviewAccepted, resolved.Review.Status) + require.NotNil(resolved.Review.AcceptedRelationshipID) + assert.Equal(resolved.Relationship.ID, *resolved.Review.AcceptedRelationshipID) + require.NotNil(resolved.Review.ReviewedBy) + assert.Equal("system", *resolved.Review.ReviewedBy) + + // A further re-import is idempotent: same edge, decision untouched. + repeated, err := f.Store.ResolveRelatedValueContext(ctx, in) + require.NoError(err) + require.NotNil(repeated.Relationship) + assert.Equal(resolved.Relationship.ID, repeated.Relationship.ID) + assert.Equal(store.RelationshipReviewAccepted, repeated.Status()) + + // Deleting the accepted edge is a decision too; re-import must not + // resurrect it (accepted_relationship_id is ON DELETE SET NULL). + edge, err := f.Store.GetPersonRelationshipContext(ctx, resolved.Relationship.ID) + require.NoError(err) + require.NoError(f.Store.DeletePersonRelationshipContext(ctx, edge.ID, edge.Revision)) + afterDelete, err := f.Store.ResolveRelatedValueContext(ctx, in) + require.NoError(err) + assert.Nil(afterDelete.Relationship) + require.NotNil(afterDelete.Review) + assert.Nil(afterDelete.Review.AcceptedRelationshipID) + views, err := f.Store.ListPersonRelationshipsContext(ctx, aliceID, store.PersonRelationshipListOptions{}) + require.NoError(err) + assert.Empty(views) +} From 12d27a3eb76262f415b31d87dfc58a66d4c388b4 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 12 Aug 2026 23:31:42 -0500 Subject: [PATCH 4/4] fix(people): record automatic RELATED links in the decision ledger An automatically resolved occurrence now inserts an already-accepted review row pointing at its edge, so the ledger covers every decided occurrence. Deleting an automatically imported relationship is now as durable as deleting a human-accepted one: the surviving accepted row with its cleared edge pointer stops re-import from recreating the edge. Co-Authored-By: Claude Fable 5 --- internal/store/person_relationship_related.go | 48 ++++++++++++++++--- .../store/person_relationship_related_test.go | 42 ++++++++++++++++ internal/store/schema.sql | 8 ++-- internal/store/schema_pg.sql | 8 ++-- 4 files changed, 94 insertions(+), 12 deletions(-) diff --git a/internal/store/person_relationship_related.go b/internal/store/person_relationship_related.go index 0979a04ba..ec2a2423e 100644 --- a/internal/store/person_relationship_related.go +++ b/internal/store/person_relationship_related.go @@ -83,7 +83,8 @@ type RelatedResolution struct { Review *RelationshipReview `json:"review,omitempty"` } -// Status returns the staged review status, or empty for an automatic edge. +// Status returns the occurrence's recorded decision, or empty when no review +// row accompanies the resolution. func (r RelatedResolution) Status() RelationshipReviewStatus { if r.Review == nil { return "" @@ -124,10 +125,12 @@ func (s *Store) ResolvePersonByVCardUIDContext(ctx context.Context, uid string) // ResolveRelatedValueContext automatically links only an exact UID plus a // recognized relationship type. All other inputs remain durable reviews. // -// An occurrence that was already reviewed keeps its decision: a rejection -// never links on re-import, a pending review is accepted together with the -// new edge, and an accepted review whose edge was deleted since is not -// resurrected. +// Every occurrence lands in the review ledger exactly once: automatic links +// are recorded as already-accepted rows, and everything else stages as +// pending. An occurrence that was already decided keeps its decision: a +// rejection never links on re-import, a pending review is accepted together +// with the new edge, and an accepted review whose edge was deleted since is +// not resurrected. func (s *Store) ResolveRelatedValueContext(ctx context.Context, in RelatedImport) (*RelatedResolution, error) { source, err := ParseProvenance(string(in.Source)) if err != nil { @@ -254,7 +257,8 @@ func (s *Store) resolveMatchedRelatedValue( } resolution = &RelatedResolution{Relationship: edge} if review == nil { - return nil + resolution.Review, err = s.recordResolvedOccurrenceTx(ctx, tx, in, matchedPersonID, edge.ID, validatedActor) + return err } if _, err := s.claimRelationshipReviewTx(ctx, tx, review.ID, RelationshipReviewAccepted, validatedActor); err != nil { return err @@ -271,6 +275,38 @@ func (s *Store) resolveMatchedRelatedValue( return resolution, nil } +// recordResolvedOccurrenceTx persists a first-seen occurrence that resolved +// automatically as an already-accepted review, so the decision ledger covers +// automatic links too: deleting the edge leaves a durable tombstone that +// stops re-import from resurrecting it. +func (s *Store) recordResolvedOccurrenceTx( + ctx context.Context, tx *loggedTx, in RelatedImport, matchedPersonID, edgeID int64, actor string, +) (*RelationshipReview, error) { + var insertedID int64 + err := tx.QueryRowContext(ctx, fmt.Sprintf(` + INSERT INTO person_relationship_reviews ( + person_id, raw_related_value, raw_related_type, value_kind, matched_person_id, + status, accepted_relationship_id, source, source_ref, vcard_property, vcard_group, + vcard_prop_id, vcard_pid, vcard_altid, created_by, reviewed_by, reviewed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s) + ON CONFLICT DO NOTHING + RETURNING id`, s.dialect.Now()), + in.PersonID, in.RawValue, in.RawType, string(in.ValueKind), matchedPersonID, + string(RelationshipReviewAccepted), edgeID, string(in.Source), normalizeNullableText(in.SourceRef), + nullableVCardText(in.VCardIdentity.Property), nullableVCardPointer(in.VCardIdentity.Group), + nullableVCardPointer(in.VCardIdentity.PropID), nullableVCardText(strings.Join(in.VCardIdentity.PID, ",")), + nullableVCardPointer(in.VCardIdentity.AltID), actor, actor, + ).Scan(&insertedID) + if errors.Is(err, sql.ErrNoRows) { + // A concurrent import recorded the same occurrence first. + return s.relationshipReviewByOccurrenceTx(ctx, tx, in) + } + if err != nil { + return nil, fmt.Errorf("record resolved relationship occurrence: %w", err) + } + return s.relationshipReviewTx(ctx, tx, insertedID) +} + // settledRelatedDecisionTx returns the standing decision for an occurrence // that was already reviewed. A rejected occurrence never links, and an // accepted one returns its recorded edge; when that edge was deleted since diff --git a/internal/store/person_relationship_related_test.go b/internal/store/person_relationship_related_test.go index 97c0ea42c..234bea6c2 100644 --- a/internal/store/person_relationship_related_test.go +++ b/internal/store/person_relationship_related_test.go @@ -506,3 +506,45 @@ func TestResolveRelatedValueAcceptsPendingReviewWhenOccurrenceBecomesResolvable( require.NoError(err) assert.Empty(views) } + +func TestResolveRelatedValueRecordsAutomaticAcceptanceAndKeepsDeletionDurable(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + aliceID, bobID := mustTwoPersons(t, f) + bob, err := f.Store.GetPerson(bobID) + require.NoError(err) + + in := store.RelatedImport{PersonID: aliceID, RawValue: bob.VCardUID, RawType: "friend", + ValueKind: store.RelatedValueKindText, Source: store.ProvenanceVCardImport, Actor: "system"} + first, err := f.Store.ResolveRelatedValueContext(ctx, in) + require.NoError(err) + require.NotNil(first.Relationship) + + // The automatic link is recorded in the decision ledger like a human + // acceptance, so the occurrence has one durable decision row. + require.NotNil(first.Review) + assert.Equal(store.RelationshipReviewAccepted, first.Review.Status) + require.NotNil(first.Review.MatchedPersonID) + assert.Equal(bobID, *first.Review.MatchedPersonID) + require.NotNil(first.Review.AcceptedRelationshipID) + assert.Equal(first.Relationship.ID, *first.Review.AcceptedRelationshipID) + require.NotNil(first.Review.ReviewedBy) + assert.Equal("system", *first.Review.ReviewedBy) + + // Deleting the imported edge must stick: the accepted row's cleared + // edge pointer is the tombstone that stops re-import resurrection. + edge, err := f.Store.GetPersonRelationshipContext(ctx, first.Relationship.ID) + require.NoError(err) + require.NoError(f.Store.DeletePersonRelationshipContext(ctx, edge.ID, edge.Revision)) + again, err := f.Store.ResolveRelatedValueContext(ctx, in) + require.NoError(err) + assert.Nil(again.Relationship) + require.NotNil(again.Review) + assert.Equal(first.Review.ID, again.Review.ID) + assert.Nil(again.Review.AcceptedRelationshipID) + views, err := f.Store.ListPersonRelationshipsContext(ctx, aliceID, store.PersonRelationshipListOptions{}) + require.NoError(err) + assert.Empty(views, "deleting an automatically imported relationship is durable") +} diff --git a/internal/store/schema.sql b/internal/store/schema.sql index b5f65a0a0..364336ff5 100644 --- a/internal/store/schema.sql +++ b/internal/store/schema.sql @@ -838,9 +838,11 @@ CREATE INDEX IF NOT EXISTS idx_person_relationships_target_active CREATE INDEX IF NOT EXISTS idx_person_relationships_type ON person_relationships(relationship_type_id); --- Imported vCard RELATED values that did not automatically resolve to a --- curated person relationship. Exact UID is the only automatic identity --- match; all other imported assertions stay here for a human decision. +-- Decision ledger for imported vCard RELATED occurrences. Exact UID plus a +-- recognized type is the only automatic match and is recorded here as an +-- already-accepted row; every other imported assertion stays pending for a +-- human decision. Decisions are durable across re-import: rejections never +-- link, and an accepted row whose edge was deleted is not resurrected. CREATE TABLE IF NOT EXISTS person_relationship_reviews ( id INTEGER PRIMARY KEY AUTOINCREMENT, person_id INTEGER NOT NULL REFERENCES persons(id) ON DELETE CASCADE, diff --git a/internal/store/schema_pg.sql b/internal/store/schema_pg.sql index 2ca0018f4..619e7bf56 100644 --- a/internal/store/schema_pg.sql +++ b/internal/store/schema_pg.sql @@ -625,9 +625,11 @@ CREATE INDEX IF NOT EXISTS idx_person_relationships_target_active CREATE INDEX IF NOT EXISTS idx_person_relationships_type ON person_relationships(relationship_type_id); --- Imported vCard RELATED values that did not automatically resolve to a --- curated person relationship. Exact UID is the only automatic identity --- match; all other imported assertions stay here for a human decision. +-- Decision ledger for imported vCard RELATED occurrences. Exact UID plus a +-- recognized type is the only automatic match and is recorded here as an +-- already-accepted row; every other imported assertion stays pending for a +-- human decision. Decisions are durable across re-import: rejections never +-- link, and an accepted row whose edge was deleted is not resurrected. CREATE TABLE IF NOT EXISTS person_relationship_reviews ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, person_id BIGINT NOT NULL REFERENCES persons(id) ON DELETE CASCADE,