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/create_subset.go b/cmd/msgvault/cmd/create_subset.go index 379de6bb3..cf6508491 100644 --- a/cmd/msgvault/cmd/create_subset.go +++ b/cmd/msgvault/cmd/create_subset.go @@ -54,7 +54,7 @@ func init() { ) createSubsetCmd.Flags().BoolVar( &subsetIncludeProfiles, "include-profiles", false, - "copy structured profile values, history, media, contact observations, and provenance; may expose sensitive personal data", + "copy structured profile values, history, media, contact observations, relationships, and provenance; may expose sensitive personal data", ) _ = createSubsetCmd.MarkFlagRequired("output") _ = createSubsetCmd.MarkFlagRequired("rows") @@ -105,7 +105,7 @@ func runCreateSubset(cmd *cobra.Command, args []string) error { } if subsetIncludeProfiles { fmt.Fprintln(os.Stderr, - "WARNING: --include-profiles copies every included person's current and historical structured profile values, media, contact observations, and provenance metadata.") + "WARNING: --include-profiles copies every included person's current and historical structured profile values, media, contact observations, relationships, and provenance metadata.") } result, err := store.CopySubsetWithOptions(srcDBPath, dstDir, subsetRows, 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..46555450f --- /dev/null +++ b/internal/store/person_relationship_related.go @@ -0,0 +1,744 @@ +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 occurrence's recorded decision, or empty when no review +// row accompanies the resolution. +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. +// +// 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 { + 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 { + return s.resolveMatchedRelatedValue(ctx, in, matchedPersonID, relationshipType, actor) + } + + 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 +} + +// 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 claimed before its edge is +// written. The ledger row is always established ahead of the edge, so a +// concurrent resolver of the same occurrence serializes on the row instead +// of committing an edge against someone else's decision. +func (s *Store) resolveMatchedRelatedValue( + ctx context.Context, in RelatedImport, matchedPersonID int64, + relationshipType RelationshipType, actor string, +) (*RelatedResolution, error) { + var resolution *RelatedResolution + err := s.retryRelatedOnBusy(ctx, func() error { + return s.withTxContext(ctx, func(tx *loggedTx) error { + review, err := s.relationshipReviewByOccurrenceTx(ctx, tx, in) + if err != nil && !errors.Is(err, ErrRelationshipReviewNotFound) { + return err + } + resolution, err = s.resolveMatchedOccurrenceTx(ctx, tx, in, review, matchedPersonID, relationshipType, actor) + return err + }) + }) + if err != nil { + return nil, err + } + return resolution, nil +} + +// retryRelatedOnBusy re-runs a whole resolution transaction when the database +// reports a busy or serialization failure. SQLite begins deferred +// transactions, so a transaction that read the occurrence ledger before its +// first write can fail its lock upgrade with SQLITE_BUSY_SNAPSHOT when a +// concurrent resolver commits first; a fresh attempt takes a new snapshot +// that observes the winner's decision. The wrapped operations are idempotent +// by construction (ON CONFLICT occurrence claims, duplicate edge reuse), so +// re-execution is safe. +func (s *Store) retryRelatedOnBusy(ctx context.Context, fn func() error) error { + const maxAttempts = 5 + var err error + for attempt := range maxAttempts { + err = fn() + if err == nil || !s.dialect.IsBusyError(err) { + return err + } + // Immediate re-runs can all land inside the winning transaction's + // still-open window on a loaded machine; a short growing pause lets + // it commit so the next snapshot observes its decision. + select { + case <-time.After(time.Duration(attempt+1) * 10 * time.Millisecond): + case <-ctx.Done(): + return ctx.Err() + } + } + return fmt.Errorf("resolve RELATED occurrence: gave up after %d attempts on busy: %w", maxAttempts, err) +} + +// resolveMatchedOccurrenceTx dispatches on the occurrence's ledger state; +// review is nil for a first sighting. A lost insert or claim race re-reads +// the winner's committed row and honors it, so the recursion is bounded to +// one step. +func (s *Store) resolveMatchedOccurrenceTx( + ctx context.Context, tx *loggedTx, in RelatedImport, review *RelationshipReview, + matchedPersonID int64, relationshipType RelationshipType, actor string, +) (*RelatedResolution, error) { + if review == nil { + staged, claimed, err := s.insertAcceptedOccurrenceTx(ctx, tx, in, matchedPersonID, actor) + if err != nil { + return nil, err + } + if !claimed { + committed, err := s.relationshipReviewByOccurrenceTx(ctx, tx, in) + if err != nil { + return nil, err + } + return s.resolveMatchedOccurrenceTx(ctx, tx, in, committed, matchedPersonID, relationshipType, actor) + } + return s.completeAcceptedOccurrenceTx(ctx, tx, in, staged.ID, matchedPersonID, relationshipType, actor) + } + if review.Status != RelationshipReviewPending { + return s.settledRelatedDecisionTx(ctx, tx, review) + } + if _, err := s.claimRelationshipReviewTx(ctx, tx, review.ID, RelationshipReviewAccepted, actor); err != nil { + if errors.Is(err, ErrRelationshipReviewNotPending) { + committed, readErr := s.relationshipReviewTx(ctx, tx, review.ID) + if readErr != nil { + return nil, readErr + } + return s.settledRelatedDecisionTx(ctx, tx, committed) + } + return nil, err + } + return s.completeAcceptedOccurrenceTx(ctx, tx, in, review.ID, matchedPersonID, relationshipType, actor) +} + +// completeAcceptedOccurrenceTx writes the matched edge (reusing the existing +// active edge when another occurrence already linked this pair) and records +// it on the claimed ledger row, so no committed state shows an accepted +// automatic link without its edge. +func (s *Store) completeAcceptedOccurrenceTx( + ctx context.Context, tx *loggedTx, in RelatedImport, reviewID int64, + matchedPersonID int64, relationshipType RelationshipType, actor string, +) (*RelatedResolution, error) { + edge, err := s.createMatchedRelatedEdgeTx(ctx, tx, in, matchedPersonID, relationshipType, actor) + if err != nil { + return nil, err + } + if err := s.recordAcceptedRelationshipTx(ctx, tx, reviewID, edge.ID); err != nil { + return nil, err + } + review, err := s.relationshipReviewTx(ctx, tx, reviewID) + if err != nil { + return nil, err + } + return &RelatedResolution{Relationship: edge, Review: review}, nil +} + +// createMatchedRelatedEdgeTx adds the canonical edge for a matched occurrence +// under a savepoint: PostgreSQL aborts a transaction whose statement raised a +// unique violation, so duplicate recovery must roll the failed INSERT back +// before it can look up the existing active edge. +func (s *Store) createMatchedRelatedEdgeTx( + ctx context.Context, tx *loggedTx, in RelatedImport, + matchedPersonID int64, relationshipType RelationshipType, actor string, +) (*PersonRelationship, error) { + // 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 nil, err + } + return s.addOrReuseActivePersonRelationshipTx(ctx, tx, input, validatedActor, notes) +} + +// addOrReuseActivePersonRelationshipTx inserts the canonical edge for input, +// returning the existing active edge instead when it is already present. The +// insert runs under a savepoint because PostgreSQL aborts a transaction whose +// statement raised a unique violation, and the caller's transaction must +// survive to look up the existing edge. +func (s *Store) addOrReuseActivePersonRelationshipTx( + ctx context.Context, tx *loggedTx, input PersonRelationshipInput, + validatedActor string, notes sql.NullString, +) (*PersonRelationship, error) { + if _, err := tx.ExecContext(ctx, `SAVEPOINT matched_related_edge`); err != nil { + return nil, fmt.Errorf("create relationship savepoint: %w", err) + } + edge, err := s.addPersonRelationshipTx(ctx, tx, input, validatedActor, notes) + if errors.Is(err, ErrPersonRelationshipDuplicate) { + if _, rollbackErr := tx.ExecContext(ctx, `ROLLBACK TO SAVEPOINT matched_related_edge`); rollbackErr != nil { + return nil, fmt.Errorf("roll back relationship savepoint: %w", rollbackErr) + } + return s.activePersonRelationshipTx(ctx, tx, input.SourcePersonID, input.TargetPersonID, input.TypeSlug) + } + if err != nil { + return nil, err + } + if _, err := tx.ExecContext(ctx, `RELEASE SAVEPOINT matched_related_edge`); err != nil { + return nil, fmt.Errorf("release relationship savepoint: %w", err) + } + return edge, nil +} + +// insertAcceptedOccurrenceTx claims a first-seen occurrence by inserting its +// accepted ledger row before the edge exists; the caller records the edge id +// in the same transaction. claimed is false when a concurrent transaction +// owns the occurrence row, in which case that row's committed status governs. +func (s *Store) insertAcceptedOccurrenceTx( + ctx context.Context, tx *loggedTx, in RelatedImport, matchedPersonID int64, actor string, +) (review *RelationshipReview, claimed bool, err 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, 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), 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) { + return nil, false, nil + } + if err != nil { + return nil, false, fmt.Errorf("record resolved relationship occurrence: %w", err) + } + review, err = s.relationshipReviewTx(ctx, tx, insertedID) + if err != nil { + return nil, false, err + } + return review, true, 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 +} + +func (s *Store) stageRelationshipReview(ctx context.Context, in RelatedImport, matchedPersonID int64, personMatched bool, actor string) (*RelationshipReview, error) { + var review *RelationshipReview + err := s.retryRelatedOnBusy(ctx, func() error { + return s.stageRelationshipReviewOnce(ctx, in, matchedPersonID, personMatched, actor, &review) + }) + if err != nil { + return nil, err + } + return review, nil +} + +func (s *Store) stageRelationshipReviewOnce(ctx context.Context, in RelatedImport, matchedPersonID int64, personMatched bool, actor string, review **RelationshipReview) error { + return 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 + }) +} + +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 +// (attaching to an already-existing active edge instead of failing on the +// duplicate), and records that edge ID in one transaction. Any edge failure +// rolls the conditional claim back, so review and edge can never diverge. +// +// 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 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 { + review, txErr := s.claimRelationshipReviewTx(ctx, tx, id, RelationshipReviewAccepted, trimmedActor) + if txErr != nil { + return txErr + } + 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 + } + // The asserted relationship may already exist as an active edge (for + // example, added manually while the review sat pending). Acceptance + // attaches to it instead of failing on the duplicate, matching + // automatic resolution. + edge, txErr = s.addOrReuseActivePersonRelationshipTx(ctx, tx, input, validatedActor, notes) + if txErr != nil { + return txErr + } + return s.recordAcceptedRelationshipTx(ctx, tx, id, edge.ID) + }) + 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 +} + +// 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(` + 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_internal_test.go b/internal/store/person_relationship_related_internal_test.go new file mode 100644 index 000000000..3974fc706 --- /dev/null +++ b/internal/store/person_relationship_related_internal_test.go @@ -0,0 +1,52 @@ +package store + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/mattn/go-sqlite3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The busy-snapshot race this retry exists for cannot be provoked +// deterministically through the public API, so the retry contract is proven +// directly: transient busy retries, other errors pass straight through, and +// persistent busy gives up at the attempt cap. +func TestRetryRelatedOnBusyRetriesTransientBusyOnly(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + s, err := Open(filepath.Join(t.TempDir(), "retry.db")) + require.NoError(err) + t.Cleanup(func() { _ = s.Close() }) + busy := sqlite3.Error{Code: sqlite3.ErrBusy} + + attempts := 0 + require.NoError(s.retryRelatedOnBusy(context.Background(), func() error { + attempts++ + if attempts < 3 { + return busy + } + return nil + })) + assert.Equal(3, attempts) + + permanent := errors.New("not busy") + attempts = 0 + err = s.retryRelatedOnBusy(context.Background(), func() error { + attempts++ + return permanent + }) + require.ErrorIs(err, permanent) + assert.Equal(1, attempts) + + attempts = 0 + err = s.retryRelatedOnBusy(context.Background(), func() error { + attempts++ + return busy + }) + require.ErrorIs(err, busy) + assert.Equal(5, attempts, "persistent busy must stop at the attempt cap") +} diff --git a/internal/store/person_relationship_related_test.go b/internal/store/person_relationship_related_test.go new file mode 100644 index 000000000..d6d7ab2bd --- /dev/null +++ b/internal/store/person_relationship_related_test.go @@ -0,0 +1,668 @@ +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(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 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) + 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.AcceptRelationshipReviewContext(ctx, staged.Review.ID, "no-such-type", bobID, "user") + require.ErrorIs(err, store.ErrRelationshipTypeNotFound) + 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) +} + +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) +} + +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) +} + +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") +} + +func TestResolveRelatedValueSecondOccurrenceReusesActiveEdge(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) + + firstRef := "card-1.vcf" + first, err := f.Store.ResolveRelatedValueContext(ctx, store.RelatedImport{ + PersonID: aliceID, RawValue: bob.VCardUID, RawType: "agent", + ValueKind: store.RelatedValueKindText, Source: store.ProvenanceVCardImport, + SourceRef: &firstRef, Actor: "system", + }) + require.NoError(err) + require.NotNil(first.Relationship) + + // A distinct occurrence of the same assertion must reuse the active + // edge and record its own accepted ledger row, inside one transaction + // (on PostgreSQL the duplicate insert must not abort it). + secondRef := "card-2.vcf" + second, err := f.Store.ResolveRelatedValueContext(ctx, store.RelatedImport{ + PersonID: aliceID, RawValue: bob.VCardUID, RawType: "agent", + ValueKind: store.RelatedValueKindText, Source: store.ProvenanceVCardImport, + SourceRef: &secondRef, Actor: "system", + }) + require.NoError(err) + require.NotNil(second.Relationship) + assert.Equal(first.Relationship.ID, second.Relationship.ID) + require.NotNil(second.Review) + assert.NotEqual(first.Review.ID, second.Review.ID) + assert.Equal(store.RelationshipReviewAccepted, second.Review.Status) + require.NotNil(second.Review.AcceptedRelationshipID) + assert.Equal(first.Relationship.ID, *second.Review.AcceptedRelationshipID) + reviews, err := f.Store.ListRelationshipReviewsContext(ctx, store.RelationshipReviewListOptions{PersonID: aliceID}) + require.NoError(err) + assert.Len(reviews, 2) +} + +func TestResolveRelatedValueConcurrentFirstImportsShareOneDecision(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: "agent", + ValueKind: store.RelatedValueKindText, Source: store.ProvenanceVCardImport, Actor: "system"} + + start := make(chan struct{}) + results := make(chan *store.RelatedResolution, 2) + errs := make(chan error, 2) + var wg sync.WaitGroup + wg.Add(2) + for range 2 { + go func() { + defer wg.Done() + <-start + resolution, resolveErr := f.Store.ResolveRelatedValueContext(ctx, in) + results <- resolution + errs <- resolveErr + }() + } + close(start) + wg.Wait() + close(results) + close(errs) + for resolveErr := range errs { + require.NoError(resolveErr) + } + edgeIDs := make(map[int64]bool) + for resolution := range results { + require.NotNil(resolution.Relationship) + edgeIDs[resolution.Relationship.ID] = true + require.NotNil(resolution.Review) + assert.Equal(store.RelationshipReviewAccepted, resolution.Review.Status) + } + assert.Len(edgeIDs, 1, "concurrent first imports must converge on one edge") + reviews, err := f.Store.ListRelationshipReviewsContext(ctx, store.RelationshipReviewListOptions{PersonID: aliceID}) + require.NoError(err) + require.Len(reviews, 1, "the occurrence must own exactly one ledger row") + require.NotNil(reviews[0].AcceptedRelationshipID) +} + +func TestAcceptRelationshipReviewAttachesToExistingActiveEdge(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: "friend", + ValueKind: store.RelatedValueKindText, Source: store.ProvenanceVCardImport, Actor: "system", + }) + require.NoError(err) + require.NotNil(staged.Review) + + existing, err := f.Store.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: aliceID, TargetPersonID: bobID, TypeSlug: "friend", + Source: store.ProvenanceUser, Actor: "user", + }) + require.NoError(err) + + // The relationship the review asserts already exists; acceptance must + // attach to it instead of failing on the duplicate, matching automatic + // resolution behavior. + edge, err := f.Store.AcceptRelationshipReviewContext(ctx, staged.Review.ID, "friend", bobID, "user") + require.NoError(err) + assert.Equal(existing.ID, edge.ID) + reviews, err := f.Store.ListRelationshipReviewsContext(ctx, store.RelationshipReviewListOptions{PersonID: aliceID}) + require.NoError(err) + require.Len(reviews, 1) + assert.Equal(store.RelationshipReviewAccepted, reviews[0].Status) + require.NotNil(reviews[0].AcceptedRelationshipID) + assert.Equal(existing.ID, *reviews[0].AcceptedRelationshipID) +} 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..6e4a8aae5 --- /dev/null +++ b/internal/store/relationship_type_seed.go @@ -0,0 +1,307 @@ +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. 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 +// 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 + } + 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, + `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 = ?, + 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) + } + return false, nil + } + 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) + } + 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 +} + +// 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 reconciliation leaves alone except for +// the symmetric-label repair in reconcileSeededRelationshipTypeTx. +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..c9feff8b1 --- /dev/null +++ b/internal/store/relationship_types.go @@ -0,0 +1,576 @@ +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 + } + } + // 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) + } + + 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..a4962fa97 --- /dev/null +++ b/internal/store/relationship_types_test.go @@ -0,0 +1,566 @@ +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) +} + +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) +} diff --git a/internal/store/schema.sql b/internal/store/schema.sql index e7f306cf4..364336ff5 100644 --- a/internal/store/schema.sql +++ b/internal/store/schema.sql @@ -690,6 +690,199 @@ 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); + +-- 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, + 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..619e7bf56 100644 --- a/internal/store/schema_pg.sql +++ b/internal/store/schema_pg.sql @@ -477,6 +477,199 @@ 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); + +-- 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, + 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/internal/store/subset.go b/internal/store/subset.go index 856154e20..a0ac9ba12 100644 --- a/internal/store/subset.go +++ b/internal/store/subset.go @@ -66,8 +66,9 @@ func CopySubset( // metadata. IncludeAttributes copies current and historical attribute values, // including their value content, provenance references, and actor metadata. // IncludeProfiles copies current and historical structured profile values, -// media, contact observations, identity-review candidates and evidence, and -// their provenance dependencies. +// media, contact observations, identity-review candidates and evidence, +// relationship types and edges between copied persons with their decision +// ledger, and their provenance dependencies. func CopySubsetWithOptions( srcDBPath, dstDir string, rowCount int, options CopySubsetOptions, ) (*CopyResult, error) { @@ -445,6 +446,9 @@ func copyData(tx *sql.Tx, rowCount int, options CopySubsetOptions) (*CopyResult, return nil, err } result.Sources += extraSources + if err := copySubsetRelationships(tx); err != nil { + return nil, err + } } if options.IncludeAttributes { @@ -827,6 +831,149 @@ func reconcileSubsetCommunicationServices(tx *sql.Tx, includeProfiles bool) erro return nil } +// copySubsetRelationships mirrors the source's relationship type catalog and +// copies the curated relationship data of copied persons. Types reconcile +// through universal_id because the destination schema is seeded with its own +// local ids; edges are copied only when both endpoints crossed the subset +// boundary; and the decision ledger keeps its rows, so a re-import into the +// subset honors the same decisions the source recorded — including deletion +// tombstones. References that did not cross the boundary (a matched person or +// accepted edge outside the subset) are cleared exactly as the live schema +// clears them on deletion. +func copySubsetRelationships(tx *sql.Tx) error { + hasRelationships, err := sourceTableExists(tx, "relationship_types") + if err != nil { + return fmt.Errorf("check relationship schema: %w", err) + } + if !hasRelationships { + return nil + } + // The destination holds only its own freshly seeded rows with nothing + // referencing them, so clearing every vCard mapping first lets the + // mirrored source values land without transient unique collisions (the + // source may have moved a mapping between types). + if _, err := tx.Exec(`UPDATE relationship_types SET vcard_related_type = NULL`); err != nil { + return fmt.Errorf("clear destination relationship type mappings: %w", err) + } + // inverse_type_id is remapped inline because SQLite evaluates CHECK + // constraints on the candidate row before conflict resolution: the + // non-canonical 'child' candidate needs a non-NULL inverse even when it + // resolves to the DO UPDATE path. + if _, err := tx.Exec(` + INSERT INTO relationship_types ( + 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 + ) + SELECT + source_type.universal_id, source_type.slug, + source_type.forward_label, source_type.reverse_label, + source_type.is_symmetric, source_type.is_canonical, + ( + SELECT destination_inverse.id + FROM src.relationship_types source_inverse + JOIN relationship_types destination_inverse + ON destination_inverse.universal_id = source_inverse.universal_id + WHERE source_inverse.id = source_type.inverse_type_id + ), + source_type.vcard_related_type, + source_type.color, source_type.icon, source_type.description, + source_type.ownership, source_type.is_deletable, + source_type.revision, source_type.created_at, source_type.updated_at + FROM src.relationship_types source_type + WHERE TRUE + ON CONFLICT(universal_id) DO UPDATE SET + slug = excluded.slug, + forward_label = excluded.forward_label, + reverse_label = excluded.reverse_label, + is_symmetric = excluded.is_symmetric, + is_canonical = excluded.is_canonical, + vcard_related_type = excluded.vcard_related_type, + color = excluded.color, + icon = excluded.icon, + description = excluded.description, + ownership = excluded.ownership, + is_deletable = excluded.is_deletable, + revision = excluded.revision, + created_at = excluded.created_at, + updated_at = excluded.updated_at`); err != nil { + return fmt.Errorf("copy relationship types: %w", err) + } + // inverse_type_id is a database-local key; relink it through universal_id + // once every type row exists. Destination-only seeds (absent from an older + // source) keep their own links. + if _, err := tx.Exec(` + UPDATE relationship_types + SET inverse_type_id = ( + SELECT destination_inverse.id + FROM src.relationship_types source_type + JOIN src.relationship_types source_inverse + ON source_inverse.id = source_type.inverse_type_id + JOIN relationship_types destination_inverse + ON destination_inverse.universal_id = source_inverse.universal_id + WHERE source_type.universal_id = relationship_types.universal_id + ) + WHERE EXISTS ( + SELECT 1 FROM src.relationship_types source_type + WHERE source_type.universal_id = relationship_types.universal_id + )`); err != nil { + return fmt.Errorf("link relationship type inverses: %w", err) + } + if _, err := tx.Exec(` + INSERT INTO person_relationships ( + id, 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, revision, created_at, updated_at + ) + SELECT + edge.id, edge.source_person_id, edge.target_person_id, + destination_type.id, + edge.start_year, edge.start_month, edge.start_day, + edge.end_year, edge.end_month, edge.end_day, + edge.status, edge.notes, edge.source, edge.source_ref, edge.confidence, + edge.vcard_property, edge.vcard_group, edge.vcard_prop_id, + edge.vcard_pid, edge.vcard_altid, + edge.created_by, edge.updated_by, edge.revision, + edge.created_at, edge.updated_at + FROM src.person_relationships edge + JOIN src.relationship_types source_type + ON source_type.id = edge.relationship_type_id + JOIN relationship_types destination_type + ON destination_type.universal_id = source_type.universal_id + WHERE edge.source_person_id IN (SELECT id FROM persons) + AND edge.target_person_id IN (SELECT id FROM persons)`); err != nil { + return fmt.Errorf("copy person relationships: %w", err) + } + if _, err := tx.Exec(` + INSERT INTO person_relationship_reviews ( + id, person_id, raw_related_value, raw_related_type, value_kind, + matched_person_id, accepted_relationship_id, status, source, + source_ref, vcard_property, vcard_group, vcard_prop_id, vcard_pid, + vcard_altid, created_by, reviewed_by, reviewed_at, + created_at, updated_at + ) + SELECT + review.id, review.person_id, review.raw_related_value, + review.raw_related_type, review.value_kind, + CASE WHEN review.matched_person_id IN (SELECT id FROM persons) + THEN review.matched_person_id END, + CASE WHEN review.accepted_relationship_id IN (SELECT id FROM person_relationships) + THEN review.accepted_relationship_id END, + review.status, review.source, review.source_ref, + review.vcard_property, review.vcard_group, review.vcard_prop_id, + review.vcard_pid, review.vcard_altid, + review.created_by, review.reviewed_by, review.reviewed_at, + review.created_at, review.updated_at + FROM src.person_relationship_reviews review + WHERE review.person_id IN (SELECT id FROM persons)`); err != nil { + return fmt.Errorf("copy person relationship reviews: %w", err) + } + return nil +} + func copyStructuredProfiles(tx *sql.Tx) (int64, error) { hasProfiles, err := sourceTableExists(tx, "person_names") if err != nil { diff --git a/internal/store/subset_test.go b/internal/store/subset_test.go index 3821282c6..e520961ef 100644 --- a/internal/store/subset_test.go +++ b/internal/store/subset_test.go @@ -2320,3 +2320,109 @@ func TestCopySubset_LegacyMessageRecipientsWithoutEnvelopeAddress(t *testing.T) ).Scan(&count), "count legacy recipients without envelope snapshots") assert.Equal(int64(4), count) } + +func TestCopySubsetPreservesRelationshipsAndDecisionLedgerWithProfiles(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + ctx := t.Context() + srcDB := createTestSourceDB(t, t.TempDir(), 5) + source, err := Open(srcDB) + require.NoError(err) + alice, _, err := source.CreatePersonFromParticipant(1) + require.NoError(err) + bob, _, err := source.CreatePersonFromParticipant(2) + require.NoError(err) + + // A user-defined type and an edge that uses it, plus a seeded-type edge, + // so the copy exercises both catalog reconciliation and id remapping. + mentor, err := source.CreateRelationshipTypeContext(ctx, RelationshipTypeInput{ + Slug: "mentor", ForwardLabel: "mentor", ReverseLabel: "mentee", + }) + require.NoError(err) + _, err = source.AddPersonRelationshipContext(ctx, PersonRelationshipInput{ + SourcePersonID: alice.ID, TargetPersonID: bob.ID, TypeSlug: "mentor", + Source: ProvenanceUser, Actor: "user", + }) + require.NoError(err) + _, err = source.AddPersonRelationshipContext(ctx, PersonRelationshipInput{ + SourcePersonID: alice.ID, TargetPersonID: bob.ID, TypeSlug: "parent", + Source: ProvenanceUser, Actor: "user", + }) + require.NoError(err) + + // An automatically imported relationship that the user then deleted: + // its accepted-with-cleared-edge tombstone must survive the copy. + in := RelatedImport{PersonID: alice.ID, RawValue: bob.VCardUID, RawType: "friend", + ValueKind: RelatedValueKindText, Source: ProvenanceVCardImport, Actor: "system"} + resolved, err := source.ResolveRelatedValueContext(ctx, in) + require.NoError(err) + require.NotNil(resolved.Relationship) + edge, err := source.GetPersonRelationshipContext(ctx, resolved.Relationship.ID) + require.NoError(err) + require.NoError(source.DeletePersonRelationshipContext(ctx, edge.ID, edge.Revision)) + require.NoError(source.Close()) + + dstDir := filepath.Join(t.TempDir(), "dst") + _, err = CopySubsetWithOptions(srcDB, dstDir, 5, CopySubsetOptions{IncludeProfiles: true}) + require.NoError(err) + destination, err := Open(filepath.Join(dstDir, "msgvault.db")) + require.NoError(err) + t.Cleanup(func() { _ = destination.Close() }) + + copiedMentor, err := destination.GetRelationshipTypeBySlugContext(ctx, "mentor") + require.NoError(err) + assert.Equal(mentor.UniversalID, copiedMentor.UniversalID) + + views, err := destination.ListPersonRelationshipsContext(ctx, alice.ID, PersonRelationshipListOptions{}) + require.NoError(err) + slugs := make([]string, 0, len(views)) + for _, view := range views { + slugs = append(slugs, view.Relationship.TypeSlug) + } + assert.ElementsMatch([]string{"mentor", "parent"}, slugs) + + // Re-importing the deleted occurrence into the subset must hit the + // copied tombstone, not recreate the relationship. + again, err := destination.ResolveRelatedValueContext(ctx, in) + require.NoError(err) + assert.Nil(again.Relationship) + require.NotNil(again.Review) + assert.Equal(RelationshipReviewAccepted, again.Review.Status) + assert.Nil(again.Review.AcceptedRelationshipID) +} + +func TestCopySubsetExcludesRelationshipsByDefault(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + ctx := t.Context() + srcDB := createTestSourceDB(t, t.TempDir(), 5) + source, err := Open(srcDB) + require.NoError(err) + alice, _, err := source.CreatePersonFromParticipant(1) + require.NoError(err) + bob, _, err := source.CreatePersonFromParticipant(2) + require.NoError(err) + _, err = source.CreateRelationshipTypeContext(ctx, RelationshipTypeInput{ + Slug: "mentor", ForwardLabel: "mentor", ReverseLabel: "mentee", + }) + require.NoError(err) + _, err = source.AddPersonRelationshipContext(ctx, PersonRelationshipInput{ + SourcePersonID: alice.ID, TargetPersonID: bob.ID, TypeSlug: "mentor", + Source: ProvenanceUser, Actor: "user", + }) + require.NoError(err) + require.NoError(source.Close()) + + dstDir := filepath.Join(t.TempDir(), "dst") + _, err = CopySubset(srcDB, dstDir, 5, false) + require.NoError(err) + destination, err := Open(filepath.Join(dstDir, "msgvault.db")) + require.NoError(err) + t.Cleanup(func() { _ = destination.Close() }) + + views, err := destination.ListPersonRelationshipsContext(ctx, alice.ID, PersonRelationshipListOptions{}) + require.NoError(err) + assert.Empty(views, "a shared subset must not copy relationships without the profiles opt-in") + _, err = destination.GetRelationshipTypeBySlugContext(ctx, "mentor") + require.ErrorIs(err, ErrRelationshipTypeNotFound) +} 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 */