Add meeting and meeting summary objects

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Antoine Bouchardy
2025-11-09 13:29:53 +01:00
committed by Bryan Frimin
parent 0a9033aaaf
commit 139f5984e2
63 changed files with 9395 additions and 1219 deletions

View File

@@ -66,4 +66,5 @@ const (
SAMLConfigurationEntityType uint16 = 42
UserAPIKeyEntityType uint16 = 43
UserAPIKeyMembershipEntityType uint16 = 44
MeetingEntityType uint16 = 45
)

307
pkg/coredata/meeting.go Normal file
View File

@@ -0,0 +1,307 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"context"
"errors"
"fmt"
"maps"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
Meeting struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
Name string `db:"name"`
Date time.Time `db:"date"`
Minutes *string `db:"minutes"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
Meetings []*Meeting
ErrMeetingNotFound struct {
Identifier string
}
ErrMeetingAlreadyExists struct {
message string
}
)
func (e ErrMeetingNotFound) Error() string {
return fmt.Sprintf("meeting not found: %s", e.Identifier)
}
func (e ErrMeetingAlreadyExists) Error() string {
return e.message
}
func (m Meeting) CursorKey(orderBy MeetingOrderField) page.CursorKey {
switch orderBy {
case MeetingOrderFieldCreatedAt:
return page.NewCursorKey(m.ID, m.CreatedAt)
case MeetingOrderFieldDate:
return page.NewCursorKey(m.ID, m.Date)
case MeetingOrderFieldName:
return page.NewCursorKey(m.ID, m.Name)
}
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
func (m *Meeting) LoadByID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
meetingID gid.GID,
) error {
q := `
SELECT
id,
organization_id,
name,
date,
minutes,
created_at,
updated_at
FROM
meetings
WHERE
%s
AND id = @meeting_id
LIMIT 1;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"meeting_id": meetingID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query meetings: %w", err)
}
meeting, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Meeting])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrMeetingNotFound{Identifier: meetingID.String()}
}
return fmt.Errorf("cannot collect meeting: %w", err)
}
*m = meeting
return nil
}
func (m *Meetings) LoadByOrganizationID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
cursor *page.Cursor[MeetingOrderField],
) error {
q := `
SELECT
id,
organization_id,
name,
date,
minutes,
created_at,
updated_at
FROM
meetings
WHERE
%s
AND organization_id = @organization_id
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.NamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query meetings: %w", err)
}
meetings, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Meeting])
if err != nil {
return fmt.Errorf("cannot collect meetings: %w", err)
}
*m = meetings
return nil
}
func (m *Meetings) CountByOrganizationID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
) (int, error) {
q := `
SELECT
COUNT(*)
FROM
meetings
WHERE
%s
AND organization_id = @organization_id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"organization_id": organizationID,
}
maps.Copy(args, scope.SQLArguments())
row := conn.QueryRow(ctx, q, args)
var count int
if err := row.Scan(&count); err != nil {
return 0, fmt.Errorf("cannot count meetings: %w", err)
}
return count, nil
}
func (m *Meeting) Insert(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
INSERT INTO
meetings (
tenant_id,
id,
organization_id,
name,
date,
minutes,
created_at,
updated_at
)
VALUES (
@tenant_id,
@meeting_id,
@organization_id,
@name,
@date,
@minutes,
@created_at,
@updated_at
);
`
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"meeting_id": m.ID,
"organization_id": m.OrganizationID,
"name": m.Name,
"date": m.Date,
"minutes": m.Minutes,
"created_at": m.CreatedAt,
"updated_at": m.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot insert meeting: %w", err)
}
return nil
}
func (m *Meeting) Update(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
UPDATE meetings
SET
name = @name,
date = @date,
minutes = @minutes,
updated_at = @updated_at
WHERE %s
AND id = @meeting_id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"meeting_id": m.ID,
"name": m.Name,
"date": m.Date,
"minutes": m.Minutes,
"updated_at": m.UpdatedAt,
}
maps.Copy(args, scope.SQLArguments())
result, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update meeting: %w", err)
}
if result.RowsAffected() == 0 {
return &ErrMeetingNotFound{Identifier: m.ID.String()}
}
return nil
}
func (m *Meeting) Delete(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
DELETE FROM meetings
WHERE %s
AND id = @meeting_id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"meeting_id": m.ID,
}
maps.Copy(args, scope.SQLArguments())
result, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot delete meeting: %w", err)
}
if result.RowsAffected() == 0 {
return &ErrMeetingNotFound{Identifier: m.ID.String()}
}
return nil
}

View File

@@ -0,0 +1,123 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"context"
"fmt"
"maps"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type (
MeetingAttendee struct {
MeetingID gid.GID `db:"meeting_id"`
OrganizationID gid.GID `db:"organization_id"`
AttendeeID gid.GID `db:"attendee_id"`
CreatedAt time.Time `db:"created_at"`
}
MeetingAttendees []*MeetingAttendee
)
func (ma *MeetingAttendees) LoadByMeetingID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
meetingID gid.GID,
) error {
q := `
SELECT
meeting_id,
attendee_id,
organization_id,
created_at
FROM
meeting_attendees
WHERE
%s
AND meeting_id = @meeting_id
ORDER BY
created_at ASC
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.NamedArgs{"meeting_id": meetingID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query meeting attendees: %w", err)
}
attendees, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[MeetingAttendee])
if err != nil {
return fmt.Errorf("cannot collect meeting attendees: %w", err)
}
*ma = attendees
return nil
}
func (ma *MeetingAttendees) Merge(
ctx context.Context,
conn pg.Conn,
scope Scoper,
meetingID gid.GID,
organizationID gid.GID,
attendeeIDs []gid.GID,
) error {
q := `
WITH attendee_ids AS (
SELECT
unnest(@attendee_ids::text[]) AS attendee_id,
@tenant_id AS tenant_id,
@meeting_id AS meeting_id,
@organization_id AS organization_id,
@created_at::timestamptz AS created_at
)
MERGE INTO meeting_attendees AS tgt
USING attendee_ids AS src
ON tgt.tenant_id = src.tenant_id
AND tgt.meeting_id = src.meeting_id
AND tgt.attendee_id = src.attendee_id
WHEN NOT MATCHED THEN
INSERT (tenant_id, meeting_id, attendee_id, organization_id, created_at)
VALUES (src.tenant_id, src.meeting_id, src.attendee_id, src.organization_id, src.created_at)
WHEN NOT MATCHED BY SOURCE
AND tgt.tenant_id = @tenant_id AND tgt.meeting_id = @meeting_id
THEN DELETE
`
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"meeting_id": meetingID,
"organization_id": organizationID,
"created_at": time.Now(),
"attendee_ids": attendeeIDs,
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot merge meeting attendees: %w", err)
}
return nil
}

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"fmt"
)
type (
MeetingOrderField string
)
const (
MeetingOrderFieldDate MeetingOrderField = "DATE"
MeetingOrderFieldName MeetingOrderField = "NAME"
MeetingOrderFieldCreatedAt MeetingOrderField = "CREATED_AT"
)
func (p MeetingOrderField) Column() string {
return string(p)
}
func (p MeetingOrderField) String() string {
return string(p)
}
func (p MeetingOrderField) IsValid() bool {
switch p {
case MeetingOrderFieldDate, MeetingOrderFieldName, MeetingOrderFieldCreatedAt:
return true
}
return false
}
func (p MeetingOrderField) MarshalText() ([]byte, error) {
return []byte(p.String()), nil
}
func (p *MeetingOrderField) UnmarshalText(text []byte) error {
*p = MeetingOrderField(text)
if !p.IsValid() {
return fmt.Errorf("%s is not a valid MeetingOrderField", string(text))
}
return nil
}

View File

@@ -0,0 +1,32 @@
CREATE TABLE meetings (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
date TIMESTAMP WITH TIME ZONE NOT NULL,
minutes TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL
);
CREATE TABLE meeting_attendees (
meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE,
attendee_id TEXT NOT NULL REFERENCES peoples(id) ON DELETE CASCADE,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
tenant_id TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
PRIMARY KEY (meeting_id, attendee_id)
);
CREATE TABLE organization_contexts (
organization_id TEXT PRIMARY KEY REFERENCES organizations(id) ON DELETE CASCADE,
tenant_id TEXT NOT NULL,
summary TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL
);
INSERT INTO organization_contexts (organization_id, tenant_id, summary, created_at, updated_at)
SELECT id AS organization_id, tenant_id, NULL AS summary, NOW() AS created_at, NOW() AS updated_at
FROM organizations
ON CONFLICT (organization_id) DO NOTHING;

View File

@@ -0,0 +1,162 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"context"
"errors"
"fmt"
"maps"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type (
OrganizationContext struct {
OrganizationID gid.GID `db:"organization_id"`
Summary *string `db:"summary"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
ErrOrganizationContextNotFound struct {
Identifier string
}
)
func (e ErrOrganizationContextNotFound) Error() string {
return fmt.Sprintf("organization context not found: %q", e.Identifier)
}
func (oc *OrganizationContext) LoadByOrganizationID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
) error {
q := `
SELECT
organization_id,
summary,
created_at,
updated_at
FROM
organization_contexts
WHERE
%s
AND organization_id = @organization_id
LIMIT 1;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query organization context: %w", err)
}
orgContext, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OrganizationContext])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrOrganizationContextNotFound{Identifier: organizationID.String()}
}
return fmt.Errorf("cannot collect organization context: %w", err)
}
*oc = orgContext
return nil
}
func (oc *OrganizationContext) Insert(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
INSERT INTO organization_contexts (
organization_id,
tenant_id,
summary,
created_at,
updated_at
) VALUES (
@organization_id,
@tenant_id,
@summary,
@created_at,
@updated_at
)
`
args := pgx.StrictNamedArgs{
"organization_id": oc.OrganizationID,
"tenant_id": scope.GetTenantID(),
"summary": oc.Summary,
"created_at": oc.CreatedAt,
"updated_at": oc.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot insert organization context: %w", err)
}
return nil
}
func (oc *OrganizationContext) Update(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
UPDATE organization_contexts
SET
summary = @summary,
updated_at = @updated_at
WHERE
%s
AND organization_id = @organization_id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"organization_id": oc.OrganizationID,
"summary": oc.Summary,
"updated_at": oc.UpdatedAt,
}
maps.Copy(args, scope.SQLArguments())
result, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update organization context: %w", err)
}
if result.RowsAffected() == 0 {
return &ErrOrganizationContextNotFound{Identifier: oc.OrganizationID.String()}
}
return nil
}

View File

@@ -21,10 +21,10 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
@@ -176,6 +176,52 @@ func (p *People) LoadByEmail(
return nil
}
func (p *Peoples) LoadByIDs(
ctx context.Context,
conn pg.Conn,
scope Scoper,
peopleIDs []gid.GID,
) error {
q := `
SELECT
id,
organization_id,
kind,
full_name,
primary_email_address,
additional_email_addresses,
position,
contract_start_date,
contract_end_date,
created_at,
updated_at
FROM
peoples
WHERE
%s
AND id = ANY(@people_ids)
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.NamedArgs{"people_ids": peopleIDs}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query people: %w", err)
}
peoples, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[People])
if err != nil {
return fmt.Errorf("cannot collect people: %w", err)
}
*p = peoples
return nil
}
func (p People) Insert(
ctx context.Context,
conn pg.Conn,
@@ -427,3 +473,72 @@ INNER JOIN signatories ON peoples.id = signatories.signed_by
return nil
}
func (p *Peoples) LoadByMeetingID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
meetingID gid.GID,
) error {
q := `
WITH people_attendees AS (
SELECT
p.id,
p.organization_id,
p.kind,
p.full_name,
p.primary_email_address,
p.additional_email_addresses,
p.position,
p.contract_start_date,
p.contract_end_date,
p.created_at,
p.updated_at,
p.tenant_id,
ma.created_at AS attendee_created_at
FROM
peoples p
INNER JOIN
meeting_attendees ma ON p.id = ma.attendee_id
WHERE
ma.meeting_id = @meeting_id
)
SELECT
id,
organization_id,
kind,
full_name,
primary_email_address,
additional_email_addresses,
position,
contract_start_date,
contract_end_date,
created_at,
updated_at
FROM
people_attendees
WHERE
%s
ORDER BY
attendee_created_at ASC
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.NamedArgs{"meeting_id": meetingID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query people: %w", err)
}
peoples, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[People])
if err != nil {
return fmt.Errorf("cannot collect people: %w", err)
}
*p = peoples
return nil
}

View File

@@ -40,7 +40,7 @@ func (car *CreateAssetRequest) Validate() error {
v := validator.New()
v.Check(car.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(car.Name, "name", validator.Required(), validator.SafeText(TitleMaxLength))
v.Check(car.Name, "name", validator.Required(), validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(car.Amount, "amount", validator.Required(), validator.Min(1))
v.Check(car.OwnerID, "owner_id", validator.Required(), validator.GID(coredata.PeopleEntityType))
v.Check(car.AssetType, "asset_type", validator.Required(), validator.OneOfSlice(coredata.AssetTypes()))
@@ -56,7 +56,7 @@ func (uar *UpdateAssetRequest) Validate() error {
v := validator.New()
v.Check(uar.ID, "id", validator.Required(), validator.GID(coredata.AssetEntityType))
v.Check(uar.Name, "name", validator.SafeText(NameMaxLength))
v.Check(uar.Name, "name", validator.SafeTextNoNewLine(NameMaxLength))
v.Check(uar.Amount, "amount", validator.Min(1))
v.Check(uar.OwnerID, "owner_id", validator.GID(coredata.PeopleEntityType))
v.Check(uar.AssetType, "asset_type", validator.OneOfSlice(coredata.AssetTypes()))

View File

@@ -64,7 +64,7 @@ func (car *CreateAuditRequest) Validate() error {
v.Check(car.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(car.FrameworkID, "framework_id", validator.Required(), validator.GID(coredata.FrameworkEntityType))
v.Check(car.Name, "name", validator.SafeText(TitleMaxLength))
v.Check(car.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(car.ValidUntil, "valid_until", validator.After(car.ValidFrom))
v.Check(car.State, "state", validator.OneOfSlice(coredata.AuditStates()))
v.Check(car.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))
@@ -76,7 +76,7 @@ func (uar *UpdateAuditRequest) Validate() error {
v := validator.New()
v.Check(uar.ID, "id", validator.Required(), validator.GID(coredata.AuditEntityType))
v.Check(uar.Name, "name", validator.SafeText(TitleMaxLength))
v.Check(uar.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(uar.ValidUntil, "valid_until", validator.After(uar.ValidFrom))
v.Check(uar.State, "state", validator.OneOfSlice(coredata.AuditStates()))
v.Check(uar.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))

View File

@@ -56,9 +56,9 @@ func (ccr *CreateControlRequest) Validate() error {
v.Check(ccr.ID, "id", validator.Required(), validator.GID(coredata.ControlEntityType))
v.Check(ccr.FrameworkID, "framework_id", validator.Required(), validator.GID(coredata.FrameworkEntityType))
v.Check(ccr.Name, "name", validator.Required(), validator.SafeText(TitleMaxLength))
v.Check(ccr.Name, "name", validator.Required(), validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(ccr.Description, "description", validator.Required(), validator.SafeText(ContentMaxLength))
v.Check(ccr.SectionTitle, "section_title", validator.Required(), validator.SafeText(TitleMaxLength))
v.Check(ccr.SectionTitle, "section_title", validator.Required(), validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(ccr.Status, "status", validator.Required(), validator.OneOfSlice(coredata.ControlStatuses()))
v.Check(ccr.ExclusionJustification, "exclusion_justification", validator.Required(), validator.SafeText(TitleMaxLength))
@@ -69,9 +69,9 @@ func (ucr *UpdateControlRequest) Validate() error {
v := validator.New()
v.Check(ucr.ID, "id", validator.Required(), validator.GID(coredata.ControlEntityType))
v.Check(ucr.Name, "name", validator.SafeText(TitleMaxLength))
v.Check(ucr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(ucr.Description, "description", validator.SafeText(ContentMaxLength))
v.Check(ucr.SectionTitle, "section_title", validator.SafeText(TitleMaxLength))
v.Check(ucr.SectionTitle, "section_title", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(ucr.Status, "status", validator.OneOfSlice(coredata.ControlStatuses()))
v.Check(ucr.ExclusionJustification, "exclusion_justification", validator.SafeText(TitleMaxLength))

View File

@@ -52,7 +52,7 @@ func (cdr *CreateDatumRequest) Validate() error {
v := validator.New()
v.Check(cdr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(cdr.Name, "name", validator.Required(), validator.SafeText(NameMaxLength))
v.Check(cdr.Name, "name", validator.SafeTextNoNewLine(NameMaxLength))
v.Check(cdr.DataClassification, "data_classification", validator.Required(), validator.OneOfSlice(coredata.DataClassifications()))
v.Check(cdr.OwnerID, "owner_id", validator.Required(), validator.GID(coredata.PeopleEntityType))
v.CheckEach(cdr.VendorIDs, "vendor_ids", func(index int, item any) {
@@ -66,7 +66,7 @@ func (udr *UpdateDatumRequest) Validate() error {
v := validator.New()
v.Check(udr.ID, "id", validator.Required(), validator.GID(coredata.DatumEntityType))
v.Check(udr.Name, "name", validator.SafeText(NameMaxLength))
v.Check(udr.Name, "name", validator.SafeTextNoNewLine(NameMaxLength))
v.Check(udr.DataClassification, "data_classification", validator.OneOfSlice(coredata.DataClassifications()))
v.Check(udr.OwnerID, "owner_id", validator.GID(coredata.PeopleEntityType))
v.CheckEach(udr.VendorIDs, "vendor_ids", func(index int, item any) {

View File

@@ -95,7 +95,7 @@ func (cdr *CreateDocumentRequest) Validate() error {
v := validator.New()
v.Check(cdr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(cdr.Title, "title", validator.Required(), validator.SafeText(TitleMaxLength))
v.Check(cdr.Title, "title", validator.Required(), validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(cdr.Content, "content", validator.Required(), validator.NotEmpty(), validator.MaxLen(documentMaxLength))
v.Check(cdr.OwnerID, "owner_id", validator.Required(), validator.GID(coredata.PeopleEntityType))
v.Check(cdr.Classification, "classification", validator.Required(), validator.OneOfSlice(coredata.DocumentClassifications()))
@@ -109,7 +109,7 @@ func (udr *UpdateDocumentRequest) Validate() error {
v := validator.New()
v.Check(udr.DocumentID, "document_id", validator.Required(), validator.GID(coredata.DocumentEntityType))
v.Check(udr.Title, "title", validator.SafeText(TitleMaxLength))
v.Check(udr.Title, "title", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(udr.OwnerID, "owner_id", validator.GID(coredata.PeopleEntityType))
v.Check(udr.Classification, "classification", validator.OneOfSlice(coredata.DocumentClassifications()))
v.Check(udr.DocumentType, "document_type", validator.OneOfSlice(coredata.DocumentTypes()))

View File

@@ -78,7 +78,7 @@ func (cfr *CreateFrameworkRequest) Validate() error {
v := validator.New()
v.Check(cfr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(cfr.Name, "name", validator.Required(), validator.SafeText(TitleMaxLength))
v.Check(cfr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(cfr.Description, "description", validator.SafeText(ContentMaxLength))
return v.Error()
@@ -88,7 +88,7 @@ func (ufr *UpdateFrameworkRequest) Validate() error {
v := validator.New()
v.Check(ufr.ID, "id", validator.Required(), validator.GID(coredata.FrameworkEntityType))
v.Check(ufr.Name, "name", validator.SafeText(TitleMaxLength))
v.Check(ufr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(ufr.Description, "description", validator.SafeText(ContentMaxLength))
return v.Error()

View File

@@ -74,7 +74,7 @@ func (cmr *CreateMeasureRequest) Validate() error {
v := validator.New()
v.Check(cmr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(cmr.Name, "name", validator.Required(), validator.SafeText(TitleMaxLength))
v.Check(cmr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(cmr.Description, "description", validator.SafeText(ContentMaxLength))
v.Check(cmr.Category, "category", validator.Required(), validator.SafeText(TitleMaxLength))
@@ -85,7 +85,7 @@ func (umr *UpdateMeasureRequest) Validate() error {
v := validator.New()
v.Check(umr.ID, "id", validator.Required(), validator.GID(coredata.MeasureEntityType))
v.Check(umr.Name, "name", validator.SafeText(TitleMaxLength))
v.Check(umr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(umr.Description, "description", validator.SafeText(ContentMaxLength))
v.Check(umr.Category, "category", validator.SafeText(TitleMaxLength))
v.Check(umr.State, "state", validator.OneOfSlice(coredata.MeasureStates()))

View File

@@ -0,0 +1,319 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package probo
import (
"context"
"fmt"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/validator"
)
type MeetingService struct {
svc *TenantService
}
type (
CreateMeetingRequest struct {
OrganizationID gid.GID
Name string
Date time.Time
AttendeeIDs []gid.GID
Minutes *string
}
UpdateMeetingRequest struct {
MeetingID gid.GID
Name *string
Date *time.Time
AttendeeIDs []gid.GID
Minutes **string
}
)
func (cmr *CreateMeetingRequest) Validate() error {
v := validator.New()
v.Check(cmr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(cmr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(cmr.Date, "date", validator.Required())
v.CheckEach(cmr.AttendeeIDs, "attendee_ids", func(index int, item any) {
v.Check(item, fmt.Sprintf("attendee_ids[%d]", index), validator.Required(), validator.GID(coredata.PeopleEntityType))
})
v.Check(cmr.Minutes, "minutes", validator.SafeText(ContentMaxLength))
return v.Error()
}
func (umr *UpdateMeetingRequest) Validate() error {
v := validator.New()
v.Check(umr.MeetingID, "meeting_id", validator.Required(), validator.GID(coredata.MeetingEntityType))
v.Check(umr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.CheckEach(umr.AttendeeIDs, "attendee_ids", func(index int, item any) {
v.Check(item, fmt.Sprintf("attendee_ids[%d]", index), validator.Required(), validator.GID(coredata.PeopleEntityType))
})
v.Check(umr.Minutes, "minutes", validator.SafeText(ContentMaxLength))
return v.Error()
}
func (s MeetingService) ListForOrganizationID(
ctx context.Context,
organizationID gid.GID,
cursor *page.Cursor[coredata.MeetingOrderField],
) (*page.Page[*coredata.Meeting, coredata.MeetingOrderField], error) {
var meetings coredata.Meetings
organization := &coredata.Organization{}
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := organization.LoadByID(ctx, conn, s.svc.scope, organizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
err := meetings.LoadByOrganizationID(
ctx,
conn,
s.svc.scope,
organization.ID,
cursor,
)
if err != nil {
return fmt.Errorf("cannot load meetings: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(meetings, cursor), nil
}
func (s MeetingService) CountForOrganizationID(
ctx context.Context,
organizationID gid.GID,
) (int, error) {
var count int
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) (err error) {
meetings := &coredata.Meetings{}
count, err = meetings.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID)
if err != nil {
return fmt.Errorf("cannot count meetings: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s MeetingService) Get(
ctx context.Context,
meetingID gid.GID,
) (*coredata.Meeting, error) {
meeting := &coredata.Meeting{}
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
return meeting.LoadByID(ctx, conn, s.svc.scope, meetingID)
},
)
if err != nil {
return nil, err
}
return meeting, nil
}
func (s MeetingService) Create(
ctx context.Context,
req CreateMeetingRequest,
) (*coredata.Meeting, error) {
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
now := time.Now()
var meeting *coredata.Meeting
organization := &coredata.Organization{}
err := s.svc.pg.WithTx(
ctx,
func(conn pg.Conn) error {
if err := organization.LoadByID(ctx, conn, s.svc.scope, req.OrganizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
meeting = &coredata.Meeting{
ID: gid.New(organization.ID.TenantID(), coredata.MeetingEntityType),
OrganizationID: organization.ID,
Name: req.Name,
Date: req.Date,
Minutes: req.Minutes,
CreatedAt: now,
UpdatedAt: now,
}
if err := meeting.Insert(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert meeting: %w", err)
}
if len(req.AttendeeIDs) > 0 {
var attendeePeople coredata.Peoples
if err := attendeePeople.LoadByIDs(ctx, conn, s.svc.scope, req.AttendeeIDs); err != nil {
return fmt.Errorf("cannot load attendees: %w", err)
}
var attendees coredata.MeetingAttendees
if err := attendees.Merge(ctx, conn, s.svc.scope, meeting.ID, organization.ID, req.AttendeeIDs); err != nil {
return fmt.Errorf("cannot merge meeting attendees: %w", err)
}
}
return nil
},
)
if err != nil {
return nil, err
}
return meeting, nil
}
func (s MeetingService) GetAttendees(
ctx context.Context,
meetingID gid.GID,
) (coredata.Peoples, error) {
var people coredata.Peoples
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
return people.LoadByMeetingID(ctx, conn, s.svc.scope, meetingID)
},
)
if err != nil {
return nil, err
}
return people, nil
}
func (s MeetingService) Update(
ctx context.Context,
req UpdateMeetingRequest,
) (*coredata.Meeting, error) {
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
meeting := &coredata.Meeting{}
err := s.svc.pg.WithTx(
ctx,
func(conn pg.Conn) error {
if err := meeting.LoadByID(ctx, conn, s.svc.scope, req.MeetingID); err != nil {
return fmt.Errorf("cannot load meeting: %w", err)
}
if req.Name != nil {
meeting.Name = *req.Name
}
if req.Date != nil {
meeting.Date = *req.Date
}
if req.Minutes != nil {
meeting.Minutes = *req.Minutes
}
meeting.UpdatedAt = time.Now()
if err := meeting.Update(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot update meeting: %w", err)
}
if req.AttendeeIDs != nil {
var attendeePeople coredata.Peoples
if err := attendeePeople.LoadByIDs(ctx, conn, s.svc.scope, req.AttendeeIDs); err != nil {
return fmt.Errorf("cannot load attendees: %w", err)
}
var attendees coredata.MeetingAttendees
if err := attendees.Merge(ctx, conn, s.svc.scope, meeting.ID, meeting.OrganizationID, req.AttendeeIDs); err != nil {
return fmt.Errorf("cannot merge meeting attendees: %w", err)
}
}
return nil
},
)
if err != nil {
return nil, err
}
return meeting, nil
}
func (s MeetingService) Delete(
ctx context.Context,
meetingID gid.GID,
) error {
meeting := &coredata.Meeting{ID: meetingID}
err := s.svc.pg.WithTx(
ctx,
func(conn pg.Conn) error {
if err := meeting.LoadByID(ctx, conn, s.svc.scope, meetingID); err != nil {
return fmt.Errorf("cannot load meeting: %w", err)
}
if err := meeting.Delete(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot delete meeting: %w", err)
}
return nil
},
)
if err != nil {
return err
}
return nil
}

View File

@@ -73,12 +73,17 @@ type (
Email **string
HeadquarterAddress **string
}
UpdateOrganizationContextRequest struct {
OrganizationID gid.GID
Summary **string
}
)
func (cor *CreateOrganizationRequest) Validate() error {
v := validator.New()
v.Check(cor.Name, "name", validator.Required(), validator.SafeText(TitleMaxLength))
v.Check(cor.Name, "name", validator.Required(), validator.SafeTextNoNewLine(TitleMaxLength))
return v.Error()
}
@@ -87,7 +92,7 @@ func (uor *UpdateOrganizationRequest) Validate() error {
v := validator.New()
v.Check(uor.ID, "id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(uor.Name, "name", validator.SafeText(TitleMaxLength))
v.Check(uor.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(uor.Description, "description", validator.SafeText(ContentMaxLength))
v.Check(uor.WebsiteURL, "website_url", validator.SafeText(2048))
v.Check(uor.Email, "email", validator.SafeText(255))
@@ -98,6 +103,15 @@ func (uor *UpdateOrganizationRequest) Validate() error {
return v.Error()
}
func (uocr *UpdateOrganizationContextRequest) Validate() error {
v := validator.New()
v.Check(uocr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(uocr.Summary, "summary", validator.SafeText(30_000))
return v.Error()
}
func (s OrganizationService) Create(
ctx context.Context,
req CreateOrganizationRequest,
@@ -138,6 +152,16 @@ func (s OrganizationService) Create(
return fmt.Errorf("cannot insert trust center: %w", err)
}
organizationContext := &coredata.OrganizationContext{
OrganizationID: organization.ID,
CreatedAt: now,
UpdatedAt: now,
}
if err := organizationContext.Insert(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert organization context: %w", err)
}
if err := s.createProboVendor(ctx, tx, organization, now); err != nil {
return fmt.Errorf("cannot create Probo vendor: %w", err)
}
@@ -178,6 +202,78 @@ func (s OrganizationService) Get(
return organization, nil
}
func (s OrganizationService) GetContextSummary(
ctx context.Context,
organizationID gid.GID,
) (*coredata.OrganizationContext, error) {
organizationContext := &coredata.OrganizationContext{}
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := organizationContext.LoadByOrganizationID(
ctx,
conn,
s.svc.scope,
organizationID,
)
if err != nil {
return fmt.Errorf("cannot load organization context: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return organizationContext, nil
}
func (s OrganizationService) UpdateContext(
ctx context.Context,
req UpdateOrganizationContextRequest,
) (*coredata.OrganizationContext, error) {
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
organization := &coredata.Organization{}
organizationContext := &coredata.OrganizationContext{}
err := s.svc.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := organization.LoadByID(ctx, tx, s.svc.scope, req.OrganizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
if err := organizationContext.LoadByOrganizationID(ctx, tx, s.svc.scope, req.OrganizationID); err != nil {
return fmt.Errorf("cannot load organization context: %w", err)
}
if req.Summary != nil {
organizationContext.Summary = *req.Summary
organizationContext.UpdatedAt = time.Now()
if err := organizationContext.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update organization context: %w", err)
}
}
return nil
},
)
if err != nil {
return nil, err
}
return organizationContext, nil
}
func (s OrganizationService) Update(
ctx context.Context,
req UpdateOrganizationRequest,
@@ -223,6 +319,10 @@ func (s OrganizationService) Update(
organization.HeadquarterAddress = *req.HeadquarterAddress
}
if err := organization.Update(ctx, s.svc.scope, tx); err != nil {
return fmt.Errorf("cannot update organization: %w", err)
}
if req.File != nil {
fileID := gid.New(s.svc.scope.GetTenantID(), coredata.FileEntityType)
objectKey, err := uuid.NewV7()

View File

@@ -58,7 +58,7 @@ func (cpr *CreatePeopleRequest) Validate() error {
v := validator.New()
v.Check(cpr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(cpr.FullName, "full_name", validator.Required(), validator.SafeText(NameMaxLength))
v.Check(cpr.FullName, "full_name", validator.SafeTextNoNewLine(NameMaxLength))
v.Check(cpr.PrimaryEmailAddress, "primary_email_address", validator.Required(), validator.NotEmpty(), validator.Email())
v.CheckEach(cpr.AdditionalEmailAddresses, "additional_email_addresses", func(index int, item any) {
v.Check(item, fmt.Sprintf("additional_email_addresses[%d]", index), validator.Required(), validator.NotEmpty(), validator.Email())
@@ -76,7 +76,7 @@ func (upr *UpdatePeopleRequest) Validate() error {
v.Check(upr.ID, "id", validator.Required(), validator.GID(coredata.PeopleEntityType))
v.Check(upr.Kind, "kind", validator.OneOfSlice(coredata.PeopleKinds()))
v.Check(upr.FullName, "full_name", validator.Required(), validator.SafeText(NameMaxLength))
v.Check(upr.FullName, "full_name", validator.SafeTextNoNewLine(NameMaxLength))
v.Check(upr.PrimaryEmailAddress, "primary_email_address", validator.NotEmpty(), validator.Email())
v.CheckEach(upr.AdditionalEmailAddresses, "additional_email_addresses", func(index int, item any) {
v.Check(item, fmt.Sprintf("additional_email_addresses[%d]", index), validator.Required(), validator.NotEmpty(), validator.Email())

View File

@@ -76,7 +76,7 @@ func (cpar *CreateProcessingActivityRequest) Validate() error {
v := validator.New()
v.Check(cpar.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(cpar.Name, "name", validator.Required(), validator.SafeText(TitleMaxLength))
v.Check(cpar.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(cpar.Purpose, "purpose", validator.SafeText(TitleMaxLength))
v.Check(cpar.DataSubjectCategory, "data_subject_category", validator.SafeText(TitleMaxLength))
v.Check(cpar.PersonalDataCategory, "personal_data_category", validator.SafeText(TitleMaxLength))
@@ -102,7 +102,7 @@ func (upar *UpdateProcessingActivityRequest) Validate() error {
v := validator.New()
v.Check(upar.ID, "id", validator.Required(), validator.GID(coredata.ProcessingActivityEntityType))
v.Check(upar.Name, "name", validator.SafeText(TitleMaxLength))
v.Check(upar.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(upar.Purpose, "purpose", validator.SafeText(TitleMaxLength))
v.Check(upar.DataSubjectCategory, "data_subject_category", validator.SafeText(TitleMaxLength))
v.Check(upar.PersonalDataCategory, "personal_data_category", validator.SafeText(TitleMaxLength))

View File

@@ -64,7 +64,7 @@ func (crr *CreateRiskRequest) Validate() error {
v := validator.New()
v.Check(crr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(crr.Name, "name", validator.Required(), validator.SafeText(TitleMaxLength))
v.Check(crr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(crr.Description, "description", validator.Required(), validator.SafeText(ContentMaxLength))
v.Check(crr.Category, "category", validator.Required(), validator.SafeText(TitleMaxLength))
v.Check(crr.Treatment, "treatment", validator.Required(), validator.OneOfSlice(coredata.RiskTreatments()))
@@ -82,7 +82,7 @@ func (urr *UpdateRiskRequest) Validate() error {
v := validator.New()
v.Check(urr.ID, "id", validator.Required(), validator.GID(coredata.RiskEntityType))
v.Check(urr.Name, "name", validator.SafeText(TitleMaxLength))
v.Check(urr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(urr.Description, "description", validator.SafeText(ContentMaxLength))
v.Check(urr.Category, "category", validator.SafeText(TitleMaxLength))
v.Check(urr.Treatment, "treatment", validator.OneOfSlice(coredata.RiskTreatments()))

View File

@@ -100,6 +100,7 @@ type (
Assets *AssetService
Data *DatumService
Audits *AuditService
Meetings *MeetingService
Reports *ReportService
TrustCenters *TrustCenterService
TrustCenterAccesses *TrustCenterAccessService
@@ -211,6 +212,7 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
tenantService.Assets = &AssetService{svc: tenantService}
tenantService.Data = &DatumService{svc: tenantService}
tenantService.Audits = &AuditService{svc: tenantService}
tenantService.Meetings = &MeetingService{svc: tenantService}
tenantService.Reports = &ReportService{svc: tenantService}
tenantService.TrustCenters = &TrustCenterService{svc: tenantService}
tenantService.TrustCenterAccesses = &TrustCenterAccessService{svc: tenantService}

View File

@@ -50,7 +50,7 @@ func (csr *CreateSnapshotRequest) Validate() error {
v := validator.New()
v.Check(csr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(csr.Name, "name", validator.Required(), validator.SafeText(TitleMaxLength))
v.Check(csr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(csr.Description, "description", validator.SafeText(ContentMaxLength))
v.Check(csr.Type, "type", validator.Required(), validator.OneOfSlice(coredata.SnapshotsTypes()))
@@ -61,7 +61,7 @@ func (usr *UpdateSnapshotRequest) Validate() error {
v := validator.New()
v.Check(usr.ID, "id", validator.Required(), validator.GID(coredata.SnapshotEntityType))
v.Check(usr.Name, "name", validator.SafeText(TitleMaxLength))
v.Check(usr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(usr.Description, "description", validator.SafeText(ContentMaxLength))
v.Check(usr.Type, "type", validator.OneOfSlice(coredata.SnapshotsTypes()))

View File

@@ -57,7 +57,7 @@ func (ctr *CreateTaskRequest) Validate() error {
v.Check(ctr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(ctr.MeasureID, "measure_id", validator.GID(coredata.MeasureEntityType))
v.Check(ctr.Name, "name", validator.Required(), validator.SafeText(TitleMaxLength))
v.Check(ctr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(ctr.Description, "description", validator.SafeText(ContentMaxLength))
v.Check(ctr.TimeEstimate, "time_estimate", validator.RangeDuration(0, 1000*time.Hour))
v.Check(ctr.AssignedToID, "assigned_to_id", validator.GID(coredata.PeopleEntityType))
@@ -69,7 +69,7 @@ func (utr *UpdateTaskRequest) Validate() error {
v := validator.New()
v.Check(utr.TaskID, "task_id", validator.Required(), validator.GID(coredata.TaskEntityType))
v.Check(utr.Name, "name", validator.SafeText(TitleMaxLength))
v.Check(utr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(utr.Description, "description", validator.SafeText(ContentMaxLength))
v.Check(utr.TimeEstimate, "time_estimate", validator.RangeDuration(0, 1000*time.Hour))
v.Check(utr.State, "state", validator.OneOfSlice(coredata.TaskStates()))

View File

@@ -60,7 +60,7 @@ func (ctcar *CreateTrustCenterAccessRequest) Validate() error {
v.Check(ctcar.TrustCenterID, "trust_center_id", validator.Required(), validator.GID(coredata.TrustCenterEntityType))
v.Check(ctcar.Email, "email", validator.Required(), validator.Email())
v.Check(ctcar.Name, "name", validator.Required(), validator.SafeText(TitleMaxLength))
v.Check(ctcar.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
return v.Error()
}
@@ -69,7 +69,7 @@ func (utcar *UpdateTrustCenterAccessRequest) Validate() error {
v := validator.New()
v.Check(utcar.ID, "id", validator.Required(), validator.GID(coredata.TrustCenterAccessEntityType))
v.Check(utcar.Name, "name", validator.SafeText(TitleMaxLength))
v.Check(utcar.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.CheckEach(utcar.DocumentIDs, "document_ids", func(index int, item any) {
v.Check(item, fmt.Sprintf("document_ids[%d]", index), validator.Required(), validator.GID(coredata.DocumentEntityType))
})

View File

@@ -60,7 +60,7 @@ func (ctcfr *CreateTrustCenterFileRequest) Validate() error {
v := validator.New()
v.Check(ctcfr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(ctcfr.Name, "name", validator.Required(), validator.SafeText(TitleMaxLength))
v.Check(ctcfr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(ctcfr.Category, "category", validator.Required(), validator.SafeText(TitleMaxLength))
v.Check(ctcfr.File, "file", validator.Required())
v.Check(ctcfr.TrustCenterVisibility, "trust_center_visibility", validator.Required(), validator.OneOfSlice(coredata.TrustCenterVisibilities()))
@@ -72,7 +72,7 @@ func (utcfr *UpdateTrustCenterFileRequest) Validate() error {
v := validator.New()
v.Check(utcfr.ID, "id", validator.Required(), validator.GID(coredata.TrustCenterFileEntityType))
v.Check(utcfr.Name, "name", validator.SafeText(TitleMaxLength))
v.Check(utcfr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(utcfr.Category, "category", validator.SafeText(TitleMaxLength))
v.Check(utcfr.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))

View File

@@ -61,7 +61,7 @@ func (ctcrr *CreateTrustCenterReferenceRequest) Validate() error {
v := validator.New()
v.Check(ctcrr.TrustCenterID, "trust_center_id", validator.Required(), validator.GID(coredata.TrustCenterEntityType))
v.Check(ctcrr.Name, "name", validator.Required(), validator.SafeText(TitleMaxLength))
v.Check(ctcrr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(ctcrr.Description, "description", validator.SafeText(ContentMaxLength))
v.Check(ctcrr.WebsiteURL, "website_url", validator.Required(), validator.SafeText(2048))
@@ -72,7 +72,7 @@ func (utcrr *UpdateTrustCenterReferenceRequest) Validate() error {
v := validator.New()
v.Check(utcrr.ID, "id", validator.Required(), validator.GID(coredata.TrustCenterReferenceEntityType))
v.Check(utcrr.Name, "name", validator.SafeText(TitleMaxLength))
v.Check(utcrr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(utcrr.Description, "description", validator.SafeText(ContentMaxLength))
v.Check(utcrr.WebsiteURL, "website_url", validator.SafeText(2048))

View File

@@ -65,7 +65,7 @@ func (utcndar *UploadTrustCenterNDARequest) Validate() error {
v := validator.New()
v.Check(utcndar.TrustCenterID, "trust_center_id", validator.Required(), validator.GID(coredata.TrustCenterEntityType))
v.Check(utcndar.FileName, "file_name", validator.Required(), validator.SafeText(TitleMaxLength))
v.Check(utcndar.FileName, "file_name", validator.SafeTextNoNewLine(TitleMaxLength))
return v.Error()
}

View File

@@ -53,7 +53,7 @@ type (
func (vbaacr *VendorBusinessAssociateAgreementCreateRequest) Validate() error {
v := validator.New()
v.Check(vbaacr.FileName, "file_name", validator.Required(), validator.SafeText(TitleMaxLength))
v.Check(vbaacr.FileName, "file_name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(vbaacr.ValidUntil, "valid_until", validator.After(vbaacr.ValidFrom))
return v.Error()

View File

@@ -44,7 +44,7 @@ type (
func (vcrcr *VendorComplianceReportCreateRequest) Validate() error {
v := validator.New()
v.Check(vcrcr.ReportName, "report_name", validator.Required(), validator.SafeText(TitleMaxLength))
v.Check(vcrcr.ReportName, "report_name", validator.SafeTextNoNewLine(TitleMaxLength))
return v.Error()
}

View File

@@ -52,7 +52,7 @@ func (cvcr *CreateVendorContactRequest) Validate() error {
v := validator.New()
v.Check(cvcr.VendorID, "vendor_id", validator.Required(), validator.GID(coredata.VendorEntityType))
v.Check(cvcr.FullName, "full_name", validator.SafeText(TitleMaxLength))
v.Check(cvcr.FullName, "fullName", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(cvcr.Email, "email", validator.Email())
v.Check(cvcr.Phone, "phone", validator.SafeText(NameMaxLength))
v.Check(cvcr.Role, "role", validator.SafeText(TitleMaxLength))
@@ -64,7 +64,7 @@ func (uvcr *UpdateVendorContactRequest) Validate() error {
v := validator.New()
v.Check(uvcr.ID, "id", validator.Required(), validator.GID(coredata.VendorContactEntityType))
v.Check(uvcr.FullName, "full_name", validator.SafeText(TitleMaxLength))
v.Check(uvcr.FullName, "fullName", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(uvcr.Email, "email", validator.Email())
v.Check(uvcr.Phone, "phone", validator.SafeText(NameMaxLength))
v.Check(uvcr.Role, "role", validator.SafeText(TitleMaxLength))

View File

@@ -53,7 +53,7 @@ type (
func (vdpacr *VendorDataPrivacyAgreementCreateRequest) Validate() error {
v := validator.New()
v.Check(vdpacr.FileName, "file_name", validator.Required(), validator.SafeText(TitleMaxLength))
v.Check(vdpacr.FileName, "file_name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(vdpacr.ValidUntil, "valid_until", validator.After(vdpacr.ValidFrom))
return v.Error()

View File

@@ -96,10 +96,10 @@ func (cvr *CreateVendorRequest) Validate() error {
v := validator.New()
v.Check(cvr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(cvr.Name, "name", validator.Required(), validator.SafeText(TitleMaxLength))
v.Check(cvr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(cvr.Description, "description", validator.SafeText(ContentMaxLength))
v.Check(cvr.HeadquarterAddress, "headquarter_address", validator.SafeText(ContentMaxLength))
v.Check(cvr.LegalName, "legal_name", validator.SafeText(TitleMaxLength))
v.Check(cvr.LegalName, "cvr.LegalName", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(cvr.WebsiteURL, "website_url", validator.SafeText(2048))
v.Check(cvr.Category, "category", validator.OneOfSlice(coredata.VendorCategories()))
v.Check(cvr.PrivacyPolicyURL, "privacy_policy_url", validator.SafeText(2048))
@@ -121,10 +121,10 @@ func (uvr *UpdateVendorRequest) Validate() error {
v := validator.New()
v.Check(uvr.ID, "id", validator.Required(), validator.GID(coredata.VendorEntityType))
v.Check(uvr.Name, "name", validator.SafeText(TitleMaxLength))
v.Check(uvr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(uvr.Description, "description", validator.SafeText(ContentMaxLength))
v.Check(uvr.HeadquarterAddress, "headquarter_address", validator.SafeText(ContentMaxLength))
v.Check(uvr.LegalName, "legal_name", validator.SafeText(TitleMaxLength))
v.Check(uvr.LegalName, "uvr.LegalName", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(uvr.WebsiteURL, "website_url", validator.SafeText(2048))
v.Check(uvr.Category, "category", validator.OneOfSlice(coredata.VendorCategories()))
v.Check(uvr.PrivacyPolicyURL, "privacy_policy_url", validator.SafeText(2048))

View File

@@ -48,7 +48,7 @@ func (cvsr *CreateVendorServiceRequest) Validate() error {
v := validator.New()
v.Check(cvsr.VendorID, "vendor_id", validator.Required(), validator.GID(coredata.VendorEntityType))
v.Check(cvsr.Name, "name", validator.Required(), validator.SafeText(TitleMaxLength))
v.Check(cvsr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(cvsr.Description, "description", validator.SafeText(ContentMaxLength))
return v.Error()
@@ -58,7 +58,7 @@ func (uvsr *UpdateVendorServiceRequest) Validate() error {
v := validator.New()
v.Check(uvsr.ID, "id", validator.Required(), validator.GID(coredata.VendorServiceEntityType))
v.Check(uvsr.Name, "name", validator.SafeText(TitleMaxLength))
v.Check(uvsr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(uvsr.Description, "description", validator.SafeText(ContentMaxLength))
return v.Error()

View File

@@ -395,6 +395,14 @@ enum DocumentOrderField
)
}
enum MeetingOrderField
@goModel(model: "go.probo.inc/probo/pkg/coredata.MeetingOrderField") {
DATE @goEnum(value: "go.probo.inc/probo/pkg/coredata.MeetingOrderFieldDate")
NAME @goEnum(value: "go.probo.inc/probo/pkg/coredata.MeetingOrderFieldName")
CREATED_AT
@goEnum(value: "go.probo.inc/probo/pkg/coredata.MeetingOrderFieldCreatedAt")
}
enum RiskOrderField
@goModel(model: "go.probo.inc/probo/pkg/coredata.RiskOrderField") {
CREATED_AT
@@ -1200,6 +1208,14 @@ input DocumentOrder
field: DocumentOrderField!
}
input MeetingOrder
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.MeetingOrderBy"
) {
direction: OrderDirection!
field: MeetingOrderField!
}
input RiskOrder
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.RiskOrderBy"
@@ -1442,6 +1458,7 @@ type Organization implements Node {
websiteUrl: String
email: String
headquarterAddress: String
context: OrganizationContext @goField(forceResolver: true)
memberships(
first: Int
@@ -1511,6 +1528,14 @@ type Organization implements Node {
filter: DocumentFilter
): DocumentConnection! @goField(forceResolver: true)
meetings(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: MeetingOrder
): MeetingConnection! @goField(forceResolver: true)
measures(
first: Int
after: CursorKey
@@ -1996,6 +2021,17 @@ type Document implements Node {
updatedAt: Datetime!
}
type Meeting implements Node {
id: ID!
name: String!
date: Datetime!
minutes: String
attendees: [People!]! @goField(forceResolver: true)
organization: Organization! @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
}
type Risk implements Node {
id: ID!
snapshotId: ID
@@ -2478,6 +2514,20 @@ type DocumentEdge {
node: Document!
}
type MeetingConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.MeetingConnection"
) {
totalCount: Int! @goField(forceResolver: true)
edges: [MeetingEdge!]!
pageInfo: PageInfo!
}
type MeetingEdge {
cursor: CursorKey!
node: Meeting!
}
type RiskConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.RiskConnection"
@@ -2677,6 +2727,9 @@ type Mutation {
updateOrganization(
input: UpdateOrganizationInput!
): UpdateOrganizationPayload!
updateOrganizationContext(
input: UpdateOrganizationContextInput!
): UpdateOrganizationContextPayload!
deleteOrganizationHorizontalLogo(
input: DeleteOrganizationHorizontalLogoInput!
): DeleteOrganizationHorizontalLogoPayload!
@@ -2895,6 +2948,10 @@ type Mutation {
createDocument(input: CreateDocumentInput!): CreateDocumentPayload!
updateDocument(input: UpdateDocumentInput!): UpdateDocumentPayload!
deleteDocument(input: DeleteDocumentInput!): DeleteDocumentPayload!
# Meeting mutations
createMeeting(input: CreateMeetingInput!): CreateMeetingPayload!
updateMeeting(input: UpdateMeetingInput!): UpdateMeetingPayload!
deleteMeeting(input: DeleteMeetingInput!): DeleteMeetingPayload!
publishDocumentVersion(
input: PublishDocumentVersionInput!
): PublishDocumentVersionPayload!
@@ -3050,6 +3107,11 @@ input UpdateOrganizationInput {
horizontalLogoFile: Upload
}
input UpdateOrganizationContextInput {
organizationId: ID!
summary: String @goField(omittable: true)
}
input DeleteOrganizationHorizontalLogoInput {
organizationId: ID!
}
@@ -3539,6 +3601,26 @@ input DeleteDocumentInput {
documentId: ID!
}
input CreateMeetingInput {
organizationId: ID!
name: String!
date: Datetime!
attendeeIds: [ID!]
minutes: String
}
input UpdateMeetingInput {
meetingId: ID!
name: String
date: Datetime
attendeeIds: [ID!]
minutes: String @goField(omittable: true)
}
input DeleteMeetingInput {
meetingId: ID!
}
input ConfirmEmailInput {
token: String!
}
@@ -3772,6 +3854,15 @@ type UpdateOrganizationPayload {
organization: Organization!
}
type UpdateOrganizationContextPayload {
context: OrganizationContext!
}
type OrganizationContext {
organizationId: ID!
summary: String
}
type DeleteOrganizationHorizontalLogoPayload {
organization: Organization!
}
@@ -4091,6 +4182,18 @@ type DeleteDocumentPayload {
deletedDocumentId: ID!
}
type CreateMeetingPayload {
meetingEdge: MeetingEdge!
}
type UpdateMeetingPayload {
meeting: Meeting!
}
type DeleteMeetingPayload {
deletedMeetingId: ID!
}
type ConfirmEmailPayload {
success: Boolean!
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,82 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package types
import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
MeetingOrderBy OrderBy[coredata.MeetingOrderField]
MeetingConnection struct {
TotalCount int
Edges []*MeetingEdge
PageInfo PageInfo
Resolver any
ParentID gid.GID
}
)
func NewMeetingConnection(
p *page.Page[*coredata.Meeting, coredata.MeetingOrderField],
parentType any,
parentID gid.GID,
) *MeetingConnection {
var edges = make([]*MeetingEdge, len(p.Data))
for i := range edges {
edges[i] = NewMeetingEdge(p.Data[i], p.Cursor.OrderBy.Field)
}
return &MeetingConnection{
Edges: edges,
PageInfo: *NewPageInfo(p),
Resolver: parentType,
ParentID: parentID,
}
}
func NewMeetingEdges(meetings []*coredata.Meeting, orderBy coredata.MeetingOrderField) []*MeetingEdge {
edges := make([]*MeetingEdge, len(meetings))
for i := range edges {
edges[i] = NewMeetingEdge(meetings[i], orderBy)
}
return edges
}
func NewMeetingEdge(meeting *coredata.Meeting, orderBy coredata.MeetingOrderField) *MeetingEdge {
return &MeetingEdge{
Cursor: meeting.CursorKey(orderBy),
Node: NewMeeting(meeting),
}
}
func NewMeeting(meeting *coredata.Meeting) *Meeting {
return &Meeting{
ID: meeting.ID,
Name: meeting.Name,
Date: meeting.Date,
Minutes: meeting.Minutes,
CreatedAt: meeting.CreatedAt,
UpdatedAt: meeting.UpdatedAt,
}
}

View File

@@ -0,0 +1,26 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package types
import (
"go.probo.inc/probo/pkg/coredata"
)
func NewOrganizationContext(oc *coredata.OrganizationContext) *OrganizationContext {
return &OrganizationContext{
OrganizationID: oc.OrganizationID,
Summary: oc.Summary,
}
}

View File

@@ -376,6 +376,18 @@ type CreateMeasurePayload struct {
MeasureEdge *MeasureEdge `json:"measureEdge"`
}
type CreateMeetingInput struct {
OrganizationID gid.GID `json:"organizationId"`
Name string `json:"name"`
Date time.Time `json:"date"`
AttendeeIds []gid.GID `json:"attendeeIds,omitempty"`
Minutes *string `json:"minutes,omitempty"`
}
type CreateMeetingPayload struct {
MeetingEdge *MeetingEdge `json:"meetingEdge"`
}
type CreateNonconformityInput struct {
OrganizationID gid.GID `json:"organizationId"`
ReferenceID string `json:"referenceId"`
@@ -446,7 +458,7 @@ type CreateProcessingActivityInput struct {
Recipients *string `json:"recipients,omitempty"`
Location *string `json:"location,omitempty"`
InternationalTransfers bool `json:"internationalTransfers"`
TransferSafeguard *coredata.ProcessingActivityTransferSafeguard `json:"transferSafeguards,omitempty"`
TransferSafeguards *coredata.ProcessingActivityTransferSafeguard `json:"transferSafeguards,omitempty"`
RetentionPeriod *string `json:"retentionPeriod,omitempty"`
SecurityMeasures *string `json:"securityMeasures,omitempty"`
DataProtectionImpactAssessment coredata.ProcessingActivityDataProtectionImpactAssessment `json:"dataProtectionImpactAssessment"`
@@ -841,6 +853,14 @@ type DeleteMeasurePayload struct {
DeletedMeasureID gid.GID `json:"deletedMeasureId"`
}
type DeleteMeetingInput struct {
MeetingID gid.GID `json:"meetingId"`
}
type DeleteMeetingPayload struct {
DeletedMeetingID gid.GID `json:"deletedMeetingId"`
}
type DeleteNonconformityInput struct {
NonconformityID gid.GID `json:"nonconformityId"`
}
@@ -1340,6 +1360,25 @@ type MeasureFilter struct {
State *coredata.MeasureState `json:"state,omitempty"`
}
type Meeting struct {
ID gid.GID `json:"id"`
Name string `json:"name"`
Date time.Time `json:"date"`
Minutes *string `json:"minutes,omitempty"`
Attendees []*People `json:"attendees"`
Organization *Organization `json:"organization"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (Meeting) IsNode() {}
func (this Meeting) GetID() gid.GID { return this.ID }
type MeetingEdge struct {
Cursor page.CursorKey `json:"cursor"`
Node *Meeting `json:"node"`
}
type Membership struct {
ID gid.GID `json:"id"`
UserID gid.GID `json:"userID"`
@@ -1432,6 +1471,7 @@ type Organization struct {
WebsiteURL *string `json:"websiteUrl,omitempty"`
Email *string `json:"email,omitempty"`
HeadquarterAddress *string `json:"headquarterAddress,omitempty"`
Context *OrganizationContext `json:"context,omitempty"`
Memberships *MembershipConnection `json:"memberships"`
Invitations *InvitationConnection `json:"invitations"`
SlackConnections *SlackConnectionConnection `json:"slackConnections"`
@@ -1440,6 +1480,7 @@ type Organization struct {
Vendors *VendorConnection `json:"vendors"`
Peoples *PeopleConnection `json:"peoples"`
Documents *DocumentConnection `json:"documents"`
Meetings *MeetingConnection `json:"meetings"`
Measures *MeasureConnection `json:"measures"`
Risks *RiskConnection `json:"risks"`
Tasks *TaskConnection `json:"tasks"`
@@ -1467,6 +1508,11 @@ type OrganizationConnection struct {
PageInfo *PageInfo `json:"pageInfo"`
}
type OrganizationContext struct {
OrganizationID gid.GID `json:"organizationId"`
Summary *string `json:"summary,omitempty"`
}
type OrganizationEdge struct {
Cursor page.CursorKey `json:"cursor"`
Node *Organization `json:"node"`
@@ -1956,6 +2002,18 @@ type UpdateMeasurePayload struct {
Measure *Measure `json:"measure"`
}
type UpdateMeetingInput struct {
MeetingID gid.GID `json:"meetingId"`
Name *string `json:"name,omitempty"`
Date *time.Time `json:"date,omitempty"`
AttendeeIds []gid.GID `json:"attendeeIds,omitempty"`
Minutes graphql.Omittable[*string] `json:"minutes,omitempty"`
}
type UpdateMeetingPayload struct {
Meeting *Meeting `json:"meeting"`
}
type UpdateNonconformityInput struct {
ID gid.GID `json:"id"`
ReferenceID *string `json:"referenceId,omitempty"`
@@ -1991,6 +2049,15 @@ type UpdateObligationPayload struct {
Obligation *Obligation `json:"obligation"`
}
type UpdateOrganizationContextInput struct {
OrganizationID gid.GID `json:"organizationId"`
Summary graphql.Omittable[*string] `json:"summary,omitempty"`
}
type UpdateOrganizationContextPayload struct {
Context *OrganizationContext `json:"context"`
}
type UpdateOrganizationInput struct {
OrganizationID gid.GID `json:"organizationId"`
Name *string `json:"name,omitempty"`

View File

@@ -1177,6 +1177,68 @@ func (r *measureConnectionResolver) TotalCount(ctx context.Context, obj *types.M
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
}
// Attendees is the resolver for the attendees field.
func (r *meetingResolver) Attendees(ctx context.Context, obj *types.Meeting) ([]*types.People, error) {
prb := r.ProboService(ctx, obj.ID.TenantID())
attendees, err := prb.Meetings.GetAttendees(ctx, obj.ID)
if err != nil {
panic(fmt.Errorf("cannot load meeting attendees: %w", err))
}
if len(attendees) == 0 {
return []*types.People{}, nil
}
people := make([]*types.People, len(attendees))
for i, attendee := range attendees {
people[i] = types.NewPeople(attendee)
}
return people, nil
}
// Organization is the resolver for the organization field.
func (r *meetingResolver) Organization(ctx context.Context, obj *types.Meeting) (*types.Organization, error) {
prb := r.ProboService(ctx, obj.ID.TenantID())
meeting, err := prb.Meetings.Get(ctx, obj.ID)
if err != nil {
var errNotFound *coredata.ErrMeetingNotFound
if errors.As(err, &errNotFound) {
return nil, gqlutils.NotFound(errNotFound)
}
panic(fmt.Errorf("cannot load meeting: %w", err))
}
organization, err := prb.Organizations.Get(ctx, meeting.OrganizationID)
if err != nil {
var errNotFound *coredata.ErrOrganizationNotFound
if errors.As(err, &errNotFound) {
return nil, gqlutils.NotFound(errNotFound)
}
panic(fmt.Errorf("cannot load organization: %w", err))
}
return types.NewOrganization(organization), nil
}
// TotalCount is the resolver for the totalCount field.
func (r *meetingConnectionResolver) TotalCount(ctx context.Context, obj *types.MeetingConnection) (int, error) {
prb := r.ProboService(ctx, obj.ParentID.TenantID())
switch obj.Resolver.(type) {
case *organizationResolver:
count, err := prb.Meetings.CountForOrganizationID(ctx, obj.ParentID)
if err != nil {
panic(fmt.Errorf("cannot count meetings: %w", err))
}
return count, nil
}
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
}
// AuthMethod is the resolver for the authMethod field.
func (r *membershipResolver) AuthMethod(ctx context.Context, obj *types.Membership) (coredata.UserAuthMethod, error) {
session := SessionFromContext(ctx)
@@ -1317,6 +1379,25 @@ func (r *mutationResolver) UpdateOrganization(ctx context.Context, input types.U
}, nil
}
// UpdateOrganizationContext is the resolver for the updateOrganizationContext field.
func (r *mutationResolver) UpdateOrganizationContext(ctx context.Context, input types.UpdateOrganizationContextInput) (*types.UpdateOrganizationContextPayload, error) {
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
req := probo.UpdateOrganizationContextRequest{
OrganizationID: input.OrganizationID,
Summary: UnwrapOmittable(input.Summary),
}
organizationContext, err := prb.Organizations.UpdateContext(ctx, req)
if err != nil {
panic(fmt.Errorf("cannot update organization context: %w", err))
}
return &types.UpdateOrganizationContextPayload{
Context: types.NewOrganizationContext(organizationContext),
}, nil
}
// DeleteOrganizationHorizontalLogo is the resolver for the deleteOrganizationHorizontalLogo field.
func (r *mutationResolver) DeleteOrganizationHorizontalLogo(ctx context.Context, input types.DeleteOrganizationHorizontalLogoInput) (*types.DeleteOrganizationHorizontalLogoPayload, error) {
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
@@ -2841,6 +2922,71 @@ func (r *mutationResolver) DeleteDocument(ctx context.Context, input types.Delet
}, nil
}
// CreateMeeting is the resolver for the createMeeting field.
func (r *mutationResolver) CreateMeeting(ctx context.Context, input types.CreateMeetingInput) (*types.CreateMeetingPayload, error) {
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
meeting, err := prb.Meetings.Create(
ctx,
probo.CreateMeetingRequest{
OrganizationID: input.OrganizationID,
Name: input.Name,
Date: input.Date,
AttendeeIDs: input.AttendeeIds,
Minutes: input.Minutes,
},
)
if err != nil {
panic(fmt.Errorf("cannot create meeting: %w", err))
}
return &types.CreateMeetingPayload{
MeetingEdge: types.NewMeetingEdge(meeting, coredata.MeetingOrderFieldCreatedAt),
}, nil
}
// UpdateMeeting is the resolver for the updateMeeting field.
func (r *mutationResolver) UpdateMeeting(ctx context.Context, input types.UpdateMeetingInput) (*types.UpdateMeetingPayload, error) {
prb := r.ProboService(ctx, input.MeetingID.TenantID())
var attendeeIDs []gid.GID
if input.AttendeeIds != nil {
attendeeIDs = input.AttendeeIds
}
meeting, err := prb.Meetings.Update(
ctx,
probo.UpdateMeetingRequest{
MeetingID: input.MeetingID,
Name: input.Name,
Date: input.Date,
AttendeeIDs: attendeeIDs,
Minutes: UnwrapOmittable(input.Minutes),
},
)
if err != nil {
panic(fmt.Errorf("cannot update meeting: %w", err))
}
return &types.UpdateMeetingPayload{
Meeting: types.NewMeeting(meeting),
}, nil
}
// DeleteMeeting is the resolver for the deleteMeeting field.
func (r *mutationResolver) DeleteMeeting(ctx context.Context, input types.DeleteMeetingInput) (*types.DeleteMeetingPayload, error) {
prb := r.ProboService(ctx, input.MeetingID.TenantID())
err := prb.Meetings.Delete(ctx, input.MeetingID)
if err != nil {
panic(fmt.Errorf("cannot delete meeting: %w", err))
}
return &types.DeleteMeetingPayload{
DeletedMeetingID: input.MeetingID,
}, nil
}
// PublishDocumentVersion is the resolver for the publishDocumentVersion field.
func (r *mutationResolver) PublishDocumentVersion(ctx context.Context, input types.PublishDocumentVersionInput) (*types.PublishDocumentVersionPayload, error) {
prb := r.ProboService(ctx, input.DocumentID.TenantID())
@@ -3560,7 +3706,7 @@ func (r *mutationResolver) CreateProcessingActivity(ctx context.Context, input t
Recipients: input.Recipients,
Location: input.Location,
InternationalTransfers: input.InternationalTransfers,
TransferSafeguard: input.TransferSafeguard,
TransferSafeguard: input.TransferSafeguards,
RetentionPeriod: input.RetentionPeriod,
SecurityMeasures: input.SecurityMeasures,
DataProtectionImpactAssessment: input.DataProtectionImpactAssessment,
@@ -4090,6 +4236,18 @@ func (r *organizationResolver) HorizontalLogoURL(ctx context.Context, obj *types
return prb.Organizations.GenerateHorizontalLogoURL(ctx, obj.ID, 1*time.Hour)
}
// Context is the resolver for the context field.
func (r *organizationResolver) Context(ctx context.Context, obj *types.Organization) (*types.OrganizationContext, error) {
prb := r.ProboService(ctx, obj.ID.TenantID())
orgContext, err := prb.Organizations.GetContextSummary(ctx, obj.ID)
if err != nil {
panic(fmt.Errorf("cannot load organization context: %w", err))
}
return types.NewOrganizationContext(orgContext), nil
}
// Memberships is the resolver for the memberships field.
func (r *organizationResolver) Memberships(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.MembershipOrderBy) (*types.MembershipConnection, error) {
pageOrderBy := page.OrderBy[coredata.MembershipOrderField]{
@@ -4311,6 +4469,31 @@ func (r *organizationResolver) Documents(ctx context.Context, obj *types.Organiz
return types.NewDocumentConnection(page, r, obj.ID, documentFilter), nil
}
// Meetings is the resolver for the meetings field.
func (r *organizationResolver) Meetings(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.MeetingOrderBy) (*types.MeetingConnection, error) {
prb := r.ProboService(ctx, obj.ID.TenantID())
pageOrderBy := page.OrderBy[coredata.MeetingOrderField]{
Field: coredata.MeetingOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.MeetingOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
page, err := prb.Meetings.ListForOrganizationID(ctx, obj.ID, cursor)
if err != nil {
panic(fmt.Errorf("cannot list organization meetings: %w", err))
}
return types.NewMeetingConnection(page, r, obj.ID), nil
}
// Measures is the resolver for the measures field.
func (r *organizationResolver) Measures(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.MeasureOrderBy, filter *types.MeasureFilter) (*types.MeasureConnection, error) {
prb := r.ProboService(ctx, obj.ID.TenantID())
@@ -5003,6 +5186,17 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
}
return types.NewTrustCenterAccess(trustCenterAccess), nil
case coredata.MeetingEntityType:
meeting, err := prb.Meetings.Get(ctx, id)
if err != nil {
var errNotFound *coredata.ErrMeetingNotFound
if errors.As(err, &errNotFound) {
return nil, gqlutils.NotFound(errNotFound)
}
panic(fmt.Errorf("cannot get meeting: %w", err))
}
return types.NewMeeting(meeting), nil
default:
}
@@ -6272,6 +6466,14 @@ func (r *Resolver) MeasureConnection() schema.MeasureConnectionResolver {
return &measureConnectionResolver{r}
}
// Meeting returns schema.MeetingResolver implementation.
func (r *Resolver) Meeting() schema.MeetingResolver { return &meetingResolver{r} }
// MeetingConnection returns schema.MeetingConnectionResolver implementation.
func (r *Resolver) MeetingConnection() schema.MeetingConnectionResolver {
return &meetingConnectionResolver{r}
}
// Membership returns schema.MembershipResolver implementation.
func (r *Resolver) Membership() schema.MembershipResolver { return &membershipResolver{r} }
@@ -6449,6 +6651,8 @@ type invitationResolver struct{ *Resolver }
type invitationConnectionResolver struct{ *Resolver }
type measureResolver struct{ *Resolver }
type measureConnectionResolver struct{ *Resolver }
type meetingResolver struct{ *Resolver }
type meetingConnectionResolver struct{ *Resolver }
type membershipResolver struct{ *Resolver }
type membershipConnectionResolver struct{ *Resolver }
type mutationResolver struct{ *Resolver }

View File

@@ -63,7 +63,7 @@ func NoHTML() ValidatorFunc {
// PrintableText validates that a string contains only printable UTF-8 characters.
// It rejects:
// - Control characters (including null bytes, tabs, line breaks except space)
// - Control characters (0x00-0x1F and 0x7F-0x9F, including null bytes and tabs, but allows newlines and carriage returns)
// - Unicode direction override characters (RLO, LRO, PDF, etc.)
// - Zero-width characters (ZWSP, ZWNJ, ZWJ, etc.)
// - Other invisible or formatting characters
@@ -71,8 +71,8 @@ func NoHTML() ValidatorFunc {
// - Replacement characters
//
// This validator does NOT check for HTML tags - use NoHTML() for that.
// This is ideal for validating titles, full names, display names, and similar text fields
// where only printable characters should be allowed.
// This validator allows line breaks (newline and carriage return) for multi-line text fields.
// Use NoNewLine() or SafeTextNoNewLine() for single-line fields that should reject line breaks.
func PrintableText() ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
@@ -96,7 +96,12 @@ func PrintableText() ValidatorFunc {
continue
}
// Reject control characters (0x00-0x1F and 0x7F-0x9F)
// Allow newline (0x0A) and carriage return (0x0D) for multi-line text
if r == '\n' || r == '\r' {
continue
}
// Reject control characters (0x00-0x1F and 0x7F-0x9F), except newline and carriage return
if r < 0x20 || (r >= 0x7F && r < 0xA0) {
return newValidationError(ErrorCodeInvalidFormat, fmt.Sprintf("contains invalid control character at position %d", i))
}
@@ -152,8 +157,46 @@ func PrintableText() ValidatorFunc {
}
}
// NoNewLine validates that a string does not contain newline or carriage return characters.
// It rejects:
// - Newline characters (\n, 0x0A)
// - Carriage return characters (\r, 0x0D)
//
// This is useful for validating single-line fields like names and titles where line breaks
// should not be allowed.
func NoNewLine() ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
str, ok := actualValue.(string)
if !ok {
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
}
if str == "" {
return nil
}
for i, r := range str {
if r == '\n' {
return newValidationError(ErrorCodeInvalidFormat, fmt.Sprintf("contains newline character at position %d", i))
}
if r == '\r' {
return newValidationError(ErrorCodeInvalidFormat, fmt.Sprintf("contains carriage return character at position %d", i))
}
}
return nil
}
}
// SafeText validates that a string is non-empty, bounded, and contains only safe content.
// It combines NotEmpty, MaxLen, NoHTML, and PrintableText validators.
// This allows newlines and carriage returns for multi-line text fields.
// Use SafeTextNoNewLine for single-line field validation that should reject line breaks.
func SafeText(maxLen int) ValidatorFunc {
validators := []ValidatorFunc{
NotEmpty(),
@@ -171,3 +214,25 @@ func SafeText(maxLen int) ValidatorFunc {
return nil
}
}
// SafeTextNoNewLine validates that a string is non-empty, bounded, and contains only safe content
// without newlines or carriage returns. It combines NotEmpty, MaxLen, NoHTML, PrintableText, and NoNewLine validators.
// This is ideal for validating single-line fields like names, titles, and display names.
func SafeTextNoNewLine(maxLen int) ValidatorFunc {
validators := []ValidatorFunc{
NotEmpty(),
MaxLen(maxLen),
NoHTML(),
PrintableText(),
NoNewLine(),
}
return func(value any) *ValidationError {
for _, validator := range validators {
if err := validator(value); err != nil {
return err
}
}
return nil
}
}

View File

@@ -381,19 +381,27 @@ func TestPrintableText(t *testing.T) {
}
})
t.Run("invalid - newline character", func(t *testing.T) {
t.Run("valid - newline character", func(t *testing.T) {
str := "test\ntext"
err := PrintableText()(&str)
if err == nil {
t.Error("expected validation error for newline character")
if err != nil {
t.Errorf("expected no error for newline character, got: %v", err)
}
})
t.Run("invalid - carriage return", func(t *testing.T) {
t.Run("valid - carriage return", func(t *testing.T) {
str := "test\rtext"
err := PrintableText()(&str)
if err == nil {
t.Error("expected validation error for carriage return")
if err != nil {
t.Errorf("expected no error for carriage return, got: %v", err)
}
})
t.Run("valid - multiple newlines", func(t *testing.T) {
str := "hello foo\nbar\n\njd"
err := PrintableText()(&str)
if err != nil {
t.Errorf("expected no error for multiple newlines, got: %v", err)
}
})
@@ -645,11 +653,19 @@ func TestSafeText(t *testing.T) {
}
})
t.Run("invalid - contains newline", func(t *testing.T) {
t.Run("valid - contains newline", func(t *testing.T) {
str := "test\ntext"
err := SafeText(100)(&str)
if err == nil {
t.Error("expected validation error for newline")
if err != nil {
t.Errorf("expected no error for newline, got: %v", err)
}
})
t.Run("valid - contains multiple newlines", func(t *testing.T) {
str := "hello foo\nbar\n\njd"
err := SafeText(100)(&str)
if err != nil {
t.Errorf("expected no error for multiple newlines, got: %v", err)
}
})
@@ -753,3 +769,157 @@ func TestSafeText(t *testing.T) {
}
})
}
func TestNoNewLine(t *testing.T) {
t.Run("valid text without newlines", func(t *testing.T) {
str := "Product Name 2024"
err := NoNewLine()(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("invalid - contains newline", func(t *testing.T) {
str := "Line 1\nLine 2"
err := NoNewLine()(&str)
if err == nil {
t.Error("expected validation error for newline")
}
if !strings.Contains(err.Message, "newline") {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("invalid - contains carriage return", func(t *testing.T) {
str := "Line 1\rLine 2"
err := NoNewLine()(&str)
if err == nil {
t.Error("expected validation error for carriage return")
}
if !strings.Contains(err.Message, "carriage return") {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("invalid - contains both newline and carriage return", func(t *testing.T) {
str := "Line 1\n\rLine 3"
err := NoNewLine()(&str)
if err == nil {
t.Error("expected validation error for newline or carriage return")
}
})
t.Run("nil pointer", func(t *testing.T) {
var str *string
err := NoNewLine()(str)
if err != nil {
t.Errorf("expected no error for nil pointer, got: %v", err)
}
})
t.Run("empty string", func(t *testing.T) {
str := ""
err := NoNewLine()(&str)
if err != nil {
t.Errorf("expected no error for empty string, got: %v", err)
}
})
}
func TestSafeTextNoNewLine(t *testing.T) {
t.Run("valid text", func(t *testing.T) {
str := "Product Name 2024"
err := SafeTextNoNewLine(100)(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("valid UTF-8 text", func(t *testing.T) {
str := "José García"
err := SafeTextNoNewLine(50)(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("invalid - contains newline", func(t *testing.T) {
str := "Line 1\nLine 2"
err := SafeTextNoNewLine(100)(&str)
if err == nil {
t.Error("expected validation error for newline")
}
if !strings.Contains(err.Message, "newline") {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("invalid - contains carriage return", func(t *testing.T) {
str := "Line 1\rLine 2"
err := SafeTextNoNewLine(100)(&str)
if err == nil {
t.Error("expected validation error for carriage return")
}
if !strings.Contains(err.Message, "carriage return") {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("invalid - empty string", func(t *testing.T) {
str := ""
err := SafeTextNoNewLine(100)(&str)
if err == nil {
t.Error("expected validation error for empty string")
}
if !strings.Contains(err.Message, "empty") && !strings.Contains(err.Message, "required") {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("invalid - exceeds max length", func(t *testing.T) {
str := "This is a very long string that exceeds the maximum length"
err := SafeTextNoNewLine(10)(&str)
if err == nil {
t.Error("expected validation error for exceeding max length")
}
if !strings.Contains(err.Message, "at most") {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("invalid - contains HTML tags", func(t *testing.T) {
str := "Hello <b>World</b>"
err := SafeTextNoNewLine(100)(&str)
if err == nil {
t.Error("expected validation error for HTML tags")
}
if !strings.Contains(err.Message, "HTML tags") {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("invalid - contains tab character", func(t *testing.T) {
str := "test\ttext"
err := SafeTextNoNewLine(100)(&str)
if err == nil {
t.Error("expected validation error for tab character")
}
})
t.Run("nil pointer", func(t *testing.T) {
var str *string
err := SafeTextNoNewLine(100)(str)
if err != nil {
t.Errorf("expected no error for nil pointer, got: %v", err)
}
})
t.Run("edge case - exactly at max length", func(t *testing.T) {
str := "12345"
err := SafeTextNoNewLine(5)(&str)
if err != nil {
t.Errorf("expected no error for string at max length, got: %v", err)
}
})
}