From fbadfbb65b40cad2baa15cc27f82c659b78b0f95 Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Thu, 17 Sep 2026 13:24:23 +0530 Subject: [PATCH 1/3] feat(store): user, organization, and project reads skip soft-deleted rows Adds two helpers next to the shared goqu dialect. live(table) is the deleted_at IS NULL filter, qualified with the table name so it stays correct inside joins. fromLive(table) is dialect.From(table) with that filter applied. Every read in the user, organization, and project repositories now goes through fromLive. The four updates keyed by name or email instead of id also add the filter, so a deleted row that shares a name with a live one is never updated by mistake. No row has deleted_at set today, so nothing changes for callers. This must be deployed everywhere before any soft-delete write ships. --- .../store/postgres/organization_repository.go | 14 +++---- .../postgres/organization_repository_test.go | 28 +++++++++++++ internal/store/postgres/postgres.go | 12 ++++++ internal/store/postgres/project_repository.go | 8 ++-- .../store/postgres/project_repository_test.go | 24 +++++++++++ internal/store/postgres/user_repository.go | 16 +++---- .../store/postgres/user_repository_test.go | 42 +++++++++++++++++-- 7 files changed, 122 insertions(+), 22 deletions(-) diff --git a/internal/store/postgres/organization_repository.go b/internal/store/postgres/organization_repository.go index e7a849e3f8..fe70b7db33 100644 --- a/internal/store/postgres/organization_repository.go +++ b/internal/store/postgres/organization_repository.go @@ -41,7 +41,7 @@ func (r OrganizationRepository) GetByID(ctx context.Context, id string) (organiz return organization.Organization{}, organization.ErrInvalidID } - query, params, err := dialect.From(TABLE_ORGANIZATIONS).Where(goqu.Ex{ + query, params, err := fromLive(TABLE_ORGANIZATIONS).Where(goqu.Ex{ "id": id, }).ToSQL() if err != nil { @@ -76,7 +76,7 @@ func (r OrganizationRepository) GetByIDs(ctx context.Context, ids []string) ([]o return nil, organization.ErrInvalidID } - query, params, err := dialect.From(TABLE_ORGANIZATIONS).Where(goqu.Ex{ + query, params, err := fromLive(TABLE_ORGANIZATIONS).Where(goqu.Ex{ "id": goqu.Op{"in": ids}, }).Where(notDisabledOrgExp).ToSQL() if err != nil { @@ -114,7 +114,7 @@ func (r OrganizationRepository) GetByName(ctx context.Context, name string) (org return organization.Organization{}, organization.ErrInvalidID } - query, params, err := dialect.From(TABLE_ORGANIZATIONS).Where(goqu.Ex{ + query, params, err := fromLive(TABLE_ORGANIZATIONS).Where(goqu.Ex{ "name": name, }).ToSQL() if err != nil { @@ -216,7 +216,7 @@ func (r OrganizationRepository) Create(ctx context.Context, org organization.Org } func (r OrganizationRepository) List(ctx context.Context, flt organization.Filter) ([]organization.Organization, error) { - stmt := dialect.From(TABLE_ORGANIZATIONS) + stmt := fromLive(TABLE_ORGANIZATIONS) if flt.State == "" { stmt = stmt.Where(notDisabledOrgExp) } else { @@ -326,7 +326,7 @@ func (r OrganizationRepository) UpdateByID(ctx context.Context, org organization } // Query to fetch org title before update - getQuery, getParams, err := dialect.From(TABLE_ORGANIZATIONS). + getQuery, getParams, err := fromLive(TABLE_ORGANIZATIONS). Select("title"). Where(goqu.Ex{"id": org.ID}).ToSQL() if err != nil { @@ -397,7 +397,7 @@ func (r OrganizationRepository) UpdateByName(ctx context.Context, org organizati } // Query to fetch org data before update - getQuery, getParams, err := dialect.From(TABLE_ORGANIZATIONS). + getQuery, getParams, err := fromLive(TABLE_ORGANIZATIONS). Select("title"). Where(goqu.Ex{"name": org.Name}).ToSQL() if err != nil { @@ -413,7 +413,7 @@ func (r OrganizationRepository) UpdateByName(ctx context.Context, org organizati }).Where( goqu.Ex{ "name": org.Name, - }).Returning(&Organization{}).ToSQL() + }, live(TABLE_ORGANIZATIONS)).Returning(&Organization{}).ToSQL() if err != nil { return organization.Organization{}, fmt.Errorf("%w: %w", errQuery, err) } diff --git a/internal/store/postgres/organization_repository_test.go b/internal/store/postgres/organization_repository_test.go index e145432d6e..c9a0edf38e 100644 --- a/internal/store/postgres/organization_repository_test.go +++ b/internal/store/postgres/organization_repository_test.go @@ -566,3 +566,31 @@ func (s *OrganizationRepositoryTestSuite) TestGetByIDs() { func TestOrganizationRepository(t *testing.T) { suite.Run(t, new(OrganizationRepositoryTestSuite)) } + +func (s *OrganizationRepositoryTestSuite) TestSkipsSoftDeletedOrganizations() { + deleted := s.orgs[0] + _, err := s.client.ExecContext(s.ctx, "UPDATE organizations SET deleted_at = now() WHERE id = $1", deleted.ID) + if err != nil { + s.T().Fatal(err) + } + + _, err = s.repository.GetByID(s.ctx, deleted.ID) + s.Assert().ErrorIs(err, organization.ErrNotExist) + + _, err = s.repository.GetByName(s.ctx, deleted.Name) + s.Assert().ErrorIs(err, organization.ErrNotExist) + + byIDs, err := s.repository.GetByIDs(s.ctx, []string{deleted.ID}) + s.Assert().NoError(err) + s.Assert().Empty(byIDs) + + got, err := s.repository.List(s.ctx, organization.Filter{}) + s.Assert().NoError(err) + s.Assert().Len(got, len(s.orgs)-1) + for _, o := range got { + s.Assert().NotEqual(deleted.ID, o.ID) + } + + _, err = s.repository.UpdateByName(s.ctx, organization.Organization{Name: deleted.Name, Title: "changed"}) + s.Assert().ErrorIs(err, organization.ErrNotExist) +} diff --git a/internal/store/postgres/postgres.go b/internal/store/postgres/postgres.go index eb3dadea6d..bb85e16ea7 100644 --- a/internal/store/postgres/postgres.go +++ b/internal/store/postgres/postgres.go @@ -6,6 +6,7 @@ import ( "github.com/doug-martin/goqu/v9" _ "github.com/doug-martin/goqu/v9/dialect/postgres" + "github.com/doug-martin/goqu/v9/exp" "github.com/jackc/pgerrcode" "github.com/jackc/pgx/v5/pgconn" _ "github.com/jackc/pgx/v5/stdlib" @@ -22,6 +23,17 @@ var ( dialect = goqu.Dialect("postgres") ) +// live is the filter every read of a soft-deleted table adds. The column is +// qualified with the table name or alias so it stays correct inside joins. +func live(table string) exp.BooleanExpression { + return goqu.I(table + ".deleted_at").IsNull() +} + +// fromLive reads only the rows that are not soft-deleted. +func fromLive(table string) *goqu.SelectDataset { + return dialect.From(table).Where(live(table)) +} + const ( TABLE_PERMISSIONS = "permissions" TABLE_GROUPS = "groups" diff --git a/internal/store/postgres/project_repository.go b/internal/store/postgres/project_repository.go index 5e09b4828f..03f8abdaef 100644 --- a/internal/store/postgres/project_repository.go +++ b/internal/store/postgres/project_repository.go @@ -39,7 +39,7 @@ func (r ProjectRepository) GetByID(ctx context.Context, id string) (project.Proj return project.Project{}, project.ErrInvalidID } - query, params, err := dialect.From(TABLE_PROJECTS).Where(goqu.ExOr{ + query, params, err := fromLive(TABLE_PROJECTS).Where(goqu.ExOr{ "id": id, }).Where(notDisabledProjectExp).ToSQL() if err != nil { @@ -74,7 +74,7 @@ func (r ProjectRepository) GetByName(ctx context.Context, name string) (project. return project.Project{}, project.ErrInvalidID } - query, params, err := dialect.From(TABLE_PROJECTS).Where(goqu.Ex{ + query, params, err := fromLive(TABLE_PROJECTS).Where(goqu.Ex{ "name": name, }).Where(notDisabledProjectExp).ToSQL() if err != nil { @@ -155,7 +155,7 @@ func (r ProjectRepository) Create(ctx context.Context, prj project.Project) (pro } func (r ProjectRepository) List(ctx context.Context, flt project.Filter) ([]project.Project, error) { - stmt := dialect.From(TABLE_PROJECTS) + stmt := fromLive(TABLE_PROJECTS) if flt.OrgID != "" { stmt = stmt.Where(goqu.Ex{ "org_id": flt.OrgID, @@ -290,7 +290,7 @@ func (r ProjectRepository) UpdateByName(ctx context.Context, prj project.Project "title": prj.Title, "metadata": marshaledMetadata, "updated_at": goqu.L("now()"), - }).Where(goqu.Ex{"name": prj.Name}).Returning(&Project{}).ToSQL() + }).Where(goqu.Ex{"name": prj.Name}, live(TABLE_PROJECTS)).Returning(&Project{}).ToSQL() if err != nil { return project.Project{}, fmt.Errorf("%w: %s", errQuery, err) } diff --git a/internal/store/postgres/project_repository_test.go b/internal/store/postgres/project_repository_test.go index 9c4167ccaa..75dfca686b 100644 --- a/internal/store/postgres/project_repository_test.go +++ b/internal/store/postgres/project_repository_test.go @@ -470,3 +470,27 @@ func (s *ProjectRepositoryTestSuite) TestUpdateByName() { func TestProjectRepository(t *testing.T) { suite.Run(t, new(ProjectRepositoryTestSuite)) } + +func (s *ProjectRepositoryTestSuite) TestSkipsSoftDeletedProjects() { + deleted := s.projects[0] + _, err := s.client.ExecContext(s.ctx, "UPDATE projects SET deleted_at = now() WHERE id = $1", deleted.ID) + if err != nil { + s.T().Fatal(err) + } + + _, err = s.repository.GetByID(s.ctx, deleted.ID) + s.Assert().ErrorIs(err, project.ErrNotExist) + + _, err = s.repository.GetByName(s.ctx, deleted.Name) + s.Assert().ErrorIs(err, project.ErrNotExist) + + got, err := s.repository.List(s.ctx, project.Filter{OrgID: deleted.Organization.ID}) + s.Assert().NoError(err) + s.Assert().NotEmpty(got) + for _, p := range got { + s.Assert().NotEqual(deleted.ID, p.ID) + } + + _, err = s.repository.UpdateByName(s.ctx, project.Project{Name: deleted.Name, Title: "changed"}) + s.Assert().ErrorIs(err, project.ErrNotExist) +} diff --git a/internal/store/postgres/user_repository.go b/internal/store/postgres/user_repository.go index 716fbf49c8..ef0da06d0e 100644 --- a/internal/store/postgres/user_repository.go +++ b/internal/store/postgres/user_repository.go @@ -47,7 +47,7 @@ func (r UserRepository) GetByID(ctx context.Context, id string) (user.User, erro } var fetchedUser User - userQuery, params, err := dialect.From(TABLE_USERS). + userQuery, params, err := fromLive(TABLE_USERS). Where(goqu.Ex{ "id": id, }).Where(notDisabledUserExp).ToSQL() @@ -85,7 +85,7 @@ func (r UserRepository) GetByName(ctx context.Context, name string) (user.User, } var fetchedUser User - query, params, err := dialect.From(TABLE_USERS). + query, params, err := fromLive(TABLE_USERS). Where(goqu.Ex{ "name": strings.ToLower(name), }).ToSQL() @@ -247,7 +247,7 @@ func (r UserRepository) List(ctx context.Context, flt user.Filter) ([]user.User, } offset := (flt.Page - 1) * flt.Limit - sqlStmt := dialect.From(TABLE_USERS). + sqlStmt := fromLive(TABLE_USERS). Select("users.id", "name", "email", "title", "avatar", "users.created_at", "users.updated_at") if len(flt.Keyword) != 0 { @@ -297,7 +297,7 @@ func (r UserRepository) GetByIDs(ctx context.Context, userIDs []string) ([]user. } var fetchedUsers []User - query, params, err := dialect.From(TABLE_USERS).Select("id", "name", "email", "title", "avatar", "state").Where( + query, params, err := fromLive(TABLE_USERS).Select("id", "name", "email", "title", "avatar", "state").Where( goqu.Ex{ "id": goqu.Op{"in": userIDs}, }).Where(notDisabledUserExp).ToSQL() @@ -352,6 +352,7 @@ func (r UserRepository) UpdateByEmail(ctx context.Context, usr user.User) (user. goqu.Ex{ "email": strings.ToLower(usr.Email), }, + live(TABLE_USERS), ).Returning(&User{}).ToSQL() if err != nil { return fmt.Errorf("%w: %s", errQuery, err) @@ -460,6 +461,7 @@ func (r UserRepository) UpdateByName(ctx context.Context, usr user.User) (user.U goqu.Ex{ "name": strings.ToLower(usr.Name), }, + live(TABLE_USERS), ).Returning(&User{}).ToSQL() if err != nil { return user.User{}, fmt.Errorf("%w: %s", errQuery, err) @@ -496,7 +498,7 @@ func (r UserRepository) GetByEmail(ctx context.Context, email string) (user.User } var fetchedUser User - query, params, err := dialect.From(TABLE_USERS).Where( + query, params, err := fromLive(TABLE_USERS).Where( goqu.Ex{ "email": strings.ToLower(email), }).Where(notDisabledUserExp).ToSQL() @@ -698,7 +700,7 @@ func (r UserRepository) PrepareDataQuery(input *rql.Query) (string, []any, error } func (r UserRepository) buildBaseQuery() *goqu.SelectDataset { - return dialect.From(TABLE_USERS).Prepared(true).Select( + return fromLive(TABLE_USERS).Prepared(true).Select( goqu.I(COLUMN_ID), goqu.I(COLUMN_NAME), goqu.I(COLUMN_EMAIL), @@ -772,7 +774,7 @@ func (r UserRepository) addSort(query *goqu.SelectDataset, input *rql.Query) (*g func (r UserRepository) PrepareGroupByQuery(input *rql.Query) (string, []any, error) { // Start with base query that includes COUNT and group by field - query := dialect.From(TABLE_USERS).Prepared(true). + query := fromLive(TABLE_USERS).Prepared(true). Select( goqu.COUNT("*").As("count"), goqu.I(TABLE_USERS+"."+input.GroupBy[0]).As("values"), diff --git a/internal/store/postgres/user_repository_test.go b/internal/store/postgres/user_repository_test.go index 6b23c9ddf0..432e2ee67c 100644 --- a/internal/store/postgres/user_repository_test.go +++ b/internal/store/postgres/user_repository_test.go @@ -653,7 +653,7 @@ func TestUserRepository_PrepareDataQuery(t *testing.T) { Offset: 10, Limit: 20, }, - wantSQL: `SELECT "id", "name", "email", "state", "avatar", "title", "created_at", "updated_at" FROM "users" WHERE (("CAST(users"."id AS TEXT)" = $1) AND ("users"."state" ILIKE $2) AND (("users"."email" IS NULL) OR ("users"."email" = $3)) AND ((CAST("id" AS TEXT) ILIKE $4) OR ("title" ILIKE $5) OR ("name" ILIKE $6) OR ("state" ILIKE $7))) ORDER BY "name" ASC, "created_at" DESC LIMIT $8 OFFSET $9`, + wantSQL: `SELECT "id", "name", "email", "state", "avatar", "title", "created_at", "updated_at" FROM "users" WHERE (("users"."deleted_at" IS NULL) AND ("CAST(users"."id AS TEXT)" = $1) AND ("users"."state" ILIKE $2) AND (("users"."email" IS NULL) OR ("users"."email" = $3)) AND ((CAST("id" AS TEXT) ILIKE $4) OR ("title" ILIKE $5) OR ("name" ILIKE $6) OR ("state" ILIKE $7))) ORDER BY "name" ASC, "created_at" DESC LIMIT $8 OFFSET $9`, wantParams: []any{int64(123), "%active%", "", "%john%", "%john%", "%john%", "%john%", int64(20), int64(10)}, }, { @@ -669,7 +669,7 @@ func TestUserRepository_PrepareDataQuery(t *testing.T) { Offset: 5, Limit: 15, }, - wantSQL: `SELECT "id", "name", "email", "state", "avatar", "title", "created_at", "updated_at" FROM "users" WHERE ("users"."state" = $1) ORDER BY "state" ASC, "name" ASC LIMIT $2 OFFSET $3`, + wantSQL: `SELECT "id", "name", "email", "state", "avatar", "title", "created_at", "updated_at" FROM "users" WHERE (("users"."deleted_at" IS NULL) AND ("users"."state" = $1)) ORDER BY "state" ASC, "name" ASC LIMIT $2 OFFSET $3`, wantParams: []any{ "active", int64(15), @@ -713,7 +713,7 @@ func TestUserRepository_PrepareGroupByQuery(t *testing.T) { GroupBy: []string{"state"}, Search: "test", }, - wantSQL: `SELECT COUNT(*) AS "count", "users"."state" AS "values" FROM "users" WHERE (("users"."state" = $1) AND ("CAST(users"."id AS TEXT)" = $2) AND ((CAST("id" AS TEXT) ILIKE $3) OR ("title" ILIKE $4) OR ("name" ILIKE $5) OR ("state" ILIKE $6))) GROUP BY "users"."state"`, + wantSQL: `SELECT COUNT(*) AS "count", "users"."state" AS "values" FROM "users" WHERE (("users"."deleted_at" IS NULL) AND ("users"."state" = $1) AND ("CAST(users"."id AS TEXT)" = $2) AND ((CAST("id" AS TEXT) ILIKE $3) OR ("title" ILIKE $4) OR ("name" ILIKE $5) OR ("state" ILIKE $6))) GROUP BY "users"."state"`, wantParams: []any{"active", int64(123), "%test%", "%test%", "%test%", "%test%"}, }, { @@ -722,7 +722,7 @@ func TestUserRepository_PrepareGroupByQuery(t *testing.T) { GroupBy: []string{"state"}, Search: "pending", }, - wantSQL: `SELECT COUNT(*) AS "count", "users"."state" AS "values" FROM "users" WHERE ((CAST("id" AS TEXT) ILIKE $1) OR ("title" ILIKE $2) OR ("name" ILIKE $3) OR ("state" ILIKE $4)) GROUP BY "users"."state"`, + wantSQL: `SELECT COUNT(*) AS "count", "users"."state" AS "values" FROM "users" WHERE (("users"."deleted_at" IS NULL) AND ((CAST("id" AS TEXT) ILIKE $1) OR ("title" ILIKE $2) OR ("name" ILIKE $3) OR ("state" ILIKE $4))) GROUP BY "users"."state"`, wantParams: []any{"%pending%", "%pending%", "%pending%", "%pending%"}, }, } @@ -743,3 +743,37 @@ func TestUserRepository_PrepareGroupByQuery(t *testing.T) { }) } } + +func (s *UserRepositoryTestSuite) TestSkipsSoftDeletedUsers() { + deleted := s.users[0] + _, err := s.client.ExecContext(s.ctx, "UPDATE users SET deleted_at = now() WHERE id = $1", deleted.ID) + if err != nil { + s.T().Fatal(err) + } + + _, err = s.repository.GetByID(s.ctx, deleted.ID) + s.Assert().ErrorIs(err, user.ErrNotExist) + + _, err = s.repository.GetByName(s.ctx, deleted.Name) + s.Assert().ErrorIs(err, user.ErrNotExist) + + _, err = s.repository.GetByEmail(s.ctx, deleted.Email) + s.Assert().ErrorIs(err, user.ErrNotExist) + + byIDs, err := s.repository.GetByIDs(s.ctx, []string{deleted.ID}) + s.Assert().NoError(err) + s.Assert().Empty(byIDs) + + got, err := s.repository.List(s.ctx, user.Filter{}) + s.Assert().NoError(err) + s.Assert().Len(got, len(s.users)-1) + for _, u := range got { + s.Assert().NotEqual(deleted.ID, u.ID) + } + + _, err = s.repository.UpdateByEmail(s.ctx, user.User{Email: deleted.Email, Title: "changed"}) + s.Assert().ErrorIs(err, user.ErrNotExist) + + _, err = s.repository.UpdateByName(s.ctx, user.User{Name: deleted.Name, Title: "changed"}) + s.Assert().ErrorIs(err, user.ErrNotExist) +} From 5e557c37d8c6a700d2a32efad5db2784bb9c12bd Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Thu, 17 Sep 2026 14:33:13 +0530 Subject: [PATCH 2/3] feat(store): updates by id skip soft-deleted rows too UpdateByID and SetState on users, organizations, and projects filtered only on the id. A soft-deleted row could still be written to. Each now adds the live filter, so the update matches nothing and reports not found, the same way a read does. Project SetState runs a plain exec and does not report a miss, so its test checks the row's state directly instead of expecting an error. --- internal/store/postgres/organization_repository.go | 3 ++- .../store/postgres/organization_repository_test.go | 8 ++++++++ internal/store/postgres/project_repository.go | 3 ++- internal/store/postgres/project_repository_test.go | 13 +++++++++++++ internal/store/postgres/user_repository.go | 2 ++ internal/store/postgres/user_repository_test.go | 8 ++++++++ 6 files changed, 35 insertions(+), 2 deletions(-) diff --git a/internal/store/postgres/organization_repository.go b/internal/store/postgres/organization_repository.go index fe70b7db33..ba15574c9f 100644 --- a/internal/store/postgres/organization_repository.go +++ b/internal/store/postgres/organization_repository.go @@ -341,7 +341,7 @@ func (r OrganizationRepository) UpdateByID(ctx context.Context, org organization "updated_at": goqu.L("now()"), }).Where(goqu.Ex{ "id": org.ID, - }).Returning(&Organization{}).ToSQL() + }, live(TABLE_ORGANIZATIONS)).Returning(&Organization{}).ToSQL() if err != nil { return organization.Organization{}, fmt.Errorf("%w: %w", errQuery, err) } @@ -464,6 +464,7 @@ func (r OrganizationRepository) SetState(ctx context.Context, id string, state o goqu.Ex{ "id": id, }, + live(TABLE_ORGANIZATIONS), ).Returning(&Organization{}).ToSQL() if err != nil { return fmt.Errorf("%w: %w", errQuery, err) diff --git a/internal/store/postgres/organization_repository_test.go b/internal/store/postgres/organization_repository_test.go index c9a0edf38e..40b1027b34 100644 --- a/internal/store/postgres/organization_repository_test.go +++ b/internal/store/postgres/organization_repository_test.go @@ -593,4 +593,12 @@ func (s *OrganizationRepositoryTestSuite) TestSkipsSoftDeletedOrganizations() { _, err = s.repository.UpdateByName(s.ctx, organization.Organization{Name: deleted.Name, Title: "changed"}) s.Assert().ErrorIs(err, organization.ErrNotExist) + + byID := deleted + byID.Title = "changed" + _, err = s.repository.UpdateByID(s.ctx, byID) + s.Assert().ErrorIs(err, organization.ErrNotExist) + + err = s.repository.SetState(s.ctx, deleted.ID, organization.Disabled) + s.Assert().ErrorIs(err, organization.ErrNotExist) } diff --git a/internal/store/postgres/project_repository.go b/internal/store/postgres/project_repository.go index 03f8abdaef..bb950a21dd 100644 --- a/internal/store/postgres/project_repository.go +++ b/internal/store/postgres/project_repository.go @@ -245,7 +245,7 @@ func (r ProjectRepository) UpdateByID(ctx context.Context, prj project.Project) "title": prj.Title, "metadata": marshaledMetadata, "updated_at": goqu.L("now()"), - }).Where(goqu.Ex{"id": prj.ID}).Returning(&Project{}).ToSQL() + }).Where(goqu.Ex{"id": prj.ID}, live(TABLE_PROJECTS)).Returning(&Project{}).ToSQL() if err != nil { return project.Project{}, fmt.Errorf("%w: %s", errQuery, err) } @@ -328,6 +328,7 @@ func (r ProjectRepository) SetState(ctx context.Context, id string, state projec goqu.Ex{ "id": id, }, + live(TABLE_PROJECTS), ).ToSQL() if err != nil { return fmt.Errorf("%w: %s", errQuery, err) diff --git a/internal/store/postgres/project_repository_test.go b/internal/store/postgres/project_repository_test.go index 75dfca686b..0fa7d8aed6 100644 --- a/internal/store/postgres/project_repository_test.go +++ b/internal/store/postgres/project_repository_test.go @@ -493,4 +493,17 @@ func (s *ProjectRepositoryTestSuite) TestSkipsSoftDeletedProjects() { _, err = s.repository.UpdateByName(s.ctx, project.Project{Name: deleted.Name, Title: "changed"}) s.Assert().ErrorIs(err, project.ErrNotExist) + + byID := deleted + byID.Title = "changed" + _, err = s.repository.UpdateByID(s.ctx, byID) + s.Assert().ErrorIs(err, project.ErrNotExist) + + // SetState runs a plain exec and does not report a miss, so check the row instead. + err = s.repository.SetState(s.ctx, deleted.ID, project.Disabled) + s.Assert().NoError(err) + var state string + err = s.client.QueryRowxContext(s.ctx, "SELECT state FROM projects WHERE id = $1", deleted.ID).Scan(&state) + s.Assert().NoError(err) + s.Assert().Equal(project.Enabled.String(), state) } diff --git a/internal/store/postgres/user_repository.go b/internal/store/postgres/user_repository.go index ef0da06d0e..70dacb5123 100644 --- a/internal/store/postgres/user_repository.go +++ b/internal/store/postgres/user_repository.go @@ -404,6 +404,7 @@ func (r UserRepository) UpdateByID(ctx context.Context, usr user.User) (user.Use goqu.Ex{ "id": usr.ID, }, + live(TABLE_USERS), ).Returning(&User{}).ToSQL() if err != nil { return fmt.Errorf("%w: %s", errQuery, err) @@ -532,6 +533,7 @@ func (r UserRepository) SetState(ctx context.Context, id string, state user.Stat goqu.Ex{ "id": id, }, + live(TABLE_USERS), ).Returning(&User{}).ToSQL() if err != nil { return fmt.Errorf("%w: %s", errQuery, err) diff --git a/internal/store/postgres/user_repository_test.go b/internal/store/postgres/user_repository_test.go index 432e2ee67c..56938c5301 100644 --- a/internal/store/postgres/user_repository_test.go +++ b/internal/store/postgres/user_repository_test.go @@ -776,4 +776,12 @@ func (s *UserRepositoryTestSuite) TestSkipsSoftDeletedUsers() { _, err = s.repository.UpdateByName(s.ctx, user.User{Name: deleted.Name, Title: "changed"}) s.Assert().ErrorIs(err, user.ErrNotExist) + + byID := deleted + byID.Title = "changed" + _, err = s.repository.UpdateByID(s.ctx, byID) + s.Assert().ErrorIs(err, user.ErrNotExist) + + err = s.repository.SetState(s.ctx, deleted.ID, user.Disabled) + s.Assert().ErrorIs(err, user.ErrNotExist) } From 121181c2c731b7047c92bacb9e600700fad1980d Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Fri, 18 Sep 2026 11:08:09 +0530 Subject: [PATCH 3/3] fix(store): project SetState reports not found like the user and org versions Project SetState ran a plain exec and returned nil whether or not a row matched. Its no-rows branch could never fire and returned the user sentinel. It now returns the updated row and maps a miss to project.ErrNotExist, the same shape as the user and organization SetState. --- internal/store/postgres/project_repository.go | 11 ++++------- internal/store/postgres/project_repository_test.go | 7 +------ 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/internal/store/postgres/project_repository.go b/internal/store/postgres/project_repository.go index bb950a21dd..be4720c734 100644 --- a/internal/store/postgres/project_repository.go +++ b/internal/store/postgres/project_repository.go @@ -11,7 +11,6 @@ import ( "github.com/doug-martin/goqu/v9" "github.com/raystack/frontier/core/organization" "github.com/raystack/frontier/core/project" - "github.com/raystack/frontier/core/user" "github.com/raystack/frontier/pkg/db" ) @@ -329,21 +328,19 @@ func (r ProjectRepository) SetState(ctx context.Context, id string, state projec "id": id, }, live(TABLE_PROJECTS), - ).ToSQL() + ).Returning(&Project{}).ToSQL() if err != nil { return fmt.Errorf("%w: %s", errQuery, err) } + var projectModel Project if err = r.dbc.WithTimeout(ctx, TABLE_PROJECTS, "SetState", func(ctx context.Context) error { - if _, err = r.dbc.DB.ExecContext(ctx, query, params...); err != nil { - return err - } - return nil + return r.dbc.QueryRowxContext(ctx, query, params...).StructScan(&projectModel) }); err != nil { err = checkPostgresError(err) switch { case errors.Is(err, sql.ErrNoRows): - return user.ErrNotExist + return project.ErrNotExist default: return err } diff --git a/internal/store/postgres/project_repository_test.go b/internal/store/postgres/project_repository_test.go index 0fa7d8aed6..31151722cb 100644 --- a/internal/store/postgres/project_repository_test.go +++ b/internal/store/postgres/project_repository_test.go @@ -499,11 +499,6 @@ func (s *ProjectRepositoryTestSuite) TestSkipsSoftDeletedProjects() { _, err = s.repository.UpdateByID(s.ctx, byID) s.Assert().ErrorIs(err, project.ErrNotExist) - // SetState runs a plain exec and does not report a miss, so check the row instead. err = s.repository.SetState(s.ctx, deleted.ID, project.Disabled) - s.Assert().NoError(err) - var state string - err = s.client.QueryRowxContext(s.ctx, "SELECT state FROM projects WHERE id = $1", deleted.ID).Scan(&state) - s.Assert().NoError(err) - s.Assert().Equal(project.Enabled.String(), state) + s.Assert().ErrorIs(err, project.ErrNotExist) }