Split inactive profile state
Replace the binary profile ACTIVE/INACTIVE model with PENDING, ACTIVE, and DEACTIVATED so invited-but-not-yet-activated members remain assignable to assets, data, and risks instead of being treated like deactivated users. Add activated_at/deactivated_at timestamps and Mark* lifecycle helpers, and update every transition (create, invite/re-invite, activation, archive, SCIM, SAML, sessions, compliance-portal grant) to the new states. Expose a multi-state states[] filter across coredata, GraphQL, MCP, and the console owner pickers, which now request ACTIVE and PENDING members. A migration renames the membership_state enum, classifies existing inactive profiles as PENDING from recent invitation activity, and backfills the new timestamp columns. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
@@ -70,6 +70,8 @@ type (
|
||||
EnterpriseOrganization *string `db:"enterprise_organization"`
|
||||
Division *string `db:"division"`
|
||||
ManagerValue *string `db:"manager_value"`
|
||||
ActivatedAt *time.Time `db:"activated_at"`
|
||||
DeactivatedAt *time.Time `db:"deactivated_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
@@ -96,6 +98,26 @@ func (p MembershipProfile) CursorKey(orderBy MembershipProfileOrderField) page.C
|
||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||
}
|
||||
|
||||
func (p *MembershipProfile) MarkPending(now time.Time) {
|
||||
p.State = ProfileStatePending
|
||||
p.ActivatedAt = nil
|
||||
p.DeactivatedAt = nil
|
||||
p.UpdatedAt = now
|
||||
}
|
||||
|
||||
func (p *MembershipProfile) MarkActive(now time.Time) {
|
||||
p.State = ProfileStateActive
|
||||
p.ActivatedAt = &now
|
||||
p.DeactivatedAt = nil
|
||||
p.UpdatedAt = now
|
||||
}
|
||||
|
||||
func (p *MembershipProfile) MarkDeactivated(now time.Time) {
|
||||
p.State = ProfileStateDeactivated
|
||||
p.DeactivatedAt = &now
|
||||
p.UpdatedAt = now
|
||||
}
|
||||
|
||||
func (p *MembershipProfile) AuthorizationAttributes(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
@@ -192,6 +214,8 @@ SELECT
|
||||
p.enterprise_organization,
|
||||
p.division,
|
||||
p.manager_value,
|
||||
p.activated_at,
|
||||
p.deactivated_at,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM
|
||||
@@ -269,6 +293,8 @@ SELECT
|
||||
p.enterprise_organization,
|
||||
p.division,
|
||||
p.manager_value,
|
||||
p.activated_at,
|
||||
p.deactivated_at,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM
|
||||
@@ -350,6 +376,8 @@ SELECT
|
||||
p.enterprise_organization,
|
||||
p.division,
|
||||
p.manager_value,
|
||||
p.activated_at,
|
||||
p.deactivated_at,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM
|
||||
@@ -430,6 +458,8 @@ SELECT
|
||||
p.enterprise_organization,
|
||||
p.division,
|
||||
p.manager_value,
|
||||
p.activated_at,
|
||||
p.deactivated_at,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM
|
||||
@@ -507,6 +537,8 @@ WITH profiles AS (
|
||||
p.enterprise_organization,
|
||||
p.division,
|
||||
p.manager_value,
|
||||
p.activated_at,
|
||||
p.deactivated_at,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM
|
||||
@@ -550,6 +582,8 @@ SELECT
|
||||
enterprise_organization,
|
||||
division,
|
||||
manager_value,
|
||||
activated_at,
|
||||
deactivated_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM profiles
|
||||
@@ -620,6 +654,8 @@ WITH profiles AS (
|
||||
p.enterprise_organization,
|
||||
p.division,
|
||||
p.manager_value,
|
||||
p.activated_at,
|
||||
p.deactivated_at,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM
|
||||
@@ -662,6 +698,8 @@ SELECT
|
||||
p.enterprise_organization,
|
||||
p.division,
|
||||
p.manager_value,
|
||||
p.activated_at,
|
||||
p.deactivated_at,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM profiles p
|
||||
@@ -743,6 +781,8 @@ profiles AS (
|
||||
mp.enterprise_organization,
|
||||
mp.division,
|
||||
mp.manager_value,
|
||||
mp.activated_at,
|
||||
mp.deactivated_at,
|
||||
mp.created_at,
|
||||
mp.updated_at
|
||||
FROM
|
||||
@@ -785,6 +825,8 @@ SELECT
|
||||
p.enterprise_organization,
|
||||
p.division,
|
||||
p.manager_value,
|
||||
p.activated_at,
|
||||
p.deactivated_at,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM profiles p
|
||||
@@ -1000,6 +1042,8 @@ INSERT INTO
|
||||
enterprise_organization,
|
||||
division,
|
||||
manager_value,
|
||||
activated_at,
|
||||
deactivated_at,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
@@ -1035,6 +1079,8 @@ VALUES (
|
||||
@enterprise_organization,
|
||||
@division,
|
||||
@manager_value,
|
||||
@activated_at,
|
||||
@deactivated_at,
|
||||
@created_at,
|
||||
@updated_at
|
||||
)
|
||||
@@ -1072,6 +1118,8 @@ VALUES (
|
||||
"enterprise_organization": p.EnterpriseOrganization,
|
||||
"division": p.Division,
|
||||
"manager_value": p.ManagerValue,
|
||||
"activated_at": p.ActivatedAt,
|
||||
"deactivated_at": p.DeactivatedAt,
|
||||
"created_at": p.CreatedAt,
|
||||
"updated_at": p.UpdatedAt,
|
||||
}
|
||||
@@ -1130,6 +1178,8 @@ SET
|
||||
enterprise_organization = @enterprise_organization,
|
||||
division = @division,
|
||||
manager_value = @manager_value,
|
||||
activated_at = @activated_at,
|
||||
deactivated_at = @deactivated_at,
|
||||
updated_at = @updated_at
|
||||
WHERE
|
||||
id = @id
|
||||
@@ -1168,6 +1218,8 @@ WHERE
|
||||
"enterprise_organization": p.EnterpriseOrganization,
|
||||
"division": p.Division,
|
||||
"manager_value": p.ManagerValue,
|
||||
"activated_at": p.ActivatedAt,
|
||||
"deactivated_at": p.DeactivatedAt,
|
||||
"updated_at": p.UpdatedAt,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
@@ -37,7 +37,7 @@ type (
|
||||
email *mail.Addr
|
||||
userName *string
|
||||
externalID *string
|
||||
state *ProfileState
|
||||
states ProfileStateValues
|
||||
source *ProfileSource
|
||||
query *string
|
||||
role *MembershipRole
|
||||
@@ -82,12 +82,17 @@ func (f *MembershipProfileFilter) WithExternalID(externalID string) *MembershipP
|
||||
}
|
||||
|
||||
func (f *MembershipProfileFilter) WithState(state ProfileState) *MembershipProfileFilter {
|
||||
f.state = &state
|
||||
f.states = ProfileStateValues{state}
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *MembershipProfileFilter) State() *ProfileState {
|
||||
return f.state
|
||||
func (f *MembershipProfileFilter) WithStates(states ...ProfileState) *MembershipProfileFilter {
|
||||
f.states = ProfileStateValues(states)
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *MembershipProfileFilter) States() []ProfileState {
|
||||
return f.states
|
||||
}
|
||||
|
||||
func (f *MembershipProfileFilter) WithSource(source ProfileSource) *MembershipProfileFilter {
|
||||
@@ -146,7 +151,7 @@ func (f *MembershipProfileFilter) SQLArguments() pgx.StrictNamedArgs {
|
||||
"with_trust_center_access": f.withCompliancePortalAccess,
|
||||
"contract_ended": f.contractEnded,
|
||||
"current_date": f.currentDate,
|
||||
"filter_state": f.state,
|
||||
"filter_states": f.states,
|
||||
"filter_source": f.source,
|
||||
"filter_query": filterQuery,
|
||||
"filter_role": f.role,
|
||||
@@ -192,8 +197,8 @@ AND (
|
||||
)
|
||||
AND (
|
||||
CASE
|
||||
WHEN @filter_state::text IS NOT NULL THEN
|
||||
p.state = @filter_state::membership_state
|
||||
WHEN @filter_states::membership_state[] IS NOT NULL THEN
|
||||
p.state = ANY(@filter_states::membership_state[])
|
||||
ELSE TRUE
|
||||
END
|
||||
)
|
||||
|
||||
55
pkg/coredata/migrations/20260728T143246Z.sql
Normal file
55
pkg/coredata/migrations/20260728T143246Z.sql
Normal file
@@ -0,0 +1,55 @@
|
||||
-- Copyright (c) 2026 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.
|
||||
|
||||
ALTER TABLE iam_membership_profiles
|
||||
ADD COLUMN activated_at TIMESTAMP WITH TIME ZONE,
|
||||
ADD COLUMN deactivated_at TIMESTAMP WITH TIME ZONE;
|
||||
|
||||
ALTER TABLE iam_membership_profiles
|
||||
ALTER COLUMN state DROP DEFAULT;
|
||||
|
||||
ALTER TYPE membership_state RENAME TO membership_state_old;
|
||||
CREATE TYPE membership_state AS ENUM ('PENDING', 'ACTIVE', 'DEACTIVATED');
|
||||
|
||||
ALTER TABLE iam_membership_profiles
|
||||
ALTER COLUMN state TYPE membership_state
|
||||
USING CASE
|
||||
WHEN state::text = 'ACTIVE' THEN 'ACTIVE'::membership_state
|
||||
ELSE 'DEACTIVATED'::membership_state
|
||||
END;
|
||||
|
||||
DROP TYPE membership_state_old;
|
||||
|
||||
UPDATE iam_membership_profiles
|
||||
SET state = 'PENDING'
|
||||
WHERE state = 'DEACTIVATED'
|
||||
AND source != 'SCIM'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM iam_invitations i
|
||||
WHERE i.user_id = iam_membership_profiles.id
|
||||
AND i.accepted_at IS NULL
|
||||
AND (
|
||||
i.expires_at >= NOW()
|
||||
OR i.created_at >= NOW() - INTERVAL '7 days'
|
||||
)
|
||||
);
|
||||
|
||||
UPDATE iam_membership_profiles
|
||||
SET activated_at = updated_at
|
||||
WHERE state = 'ACTIVE';
|
||||
|
||||
UPDATE iam_membership_profiles
|
||||
SET deactivated_at = NOW()
|
||||
WHERE state = 'DEACTIVATED';
|
||||
@@ -21,15 +21,21 @@
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ProfileState string
|
||||
type (
|
||||
ProfileState string
|
||||
ProfileStateValues []ProfileState
|
||||
)
|
||||
|
||||
const (
|
||||
ProfileStateActive ProfileState = "ACTIVE"
|
||||
ProfileStateInactive ProfileState = "INACTIVE"
|
||||
ProfileStatePending ProfileState = "PENDING"
|
||||
ProfileStateActive ProfileState = "ACTIVE"
|
||||
ProfileStateDeactivated ProfileState = "DEACTIVATED"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -40,16 +46,18 @@ var (
|
||||
|
||||
func ProfileStates() []ProfileState {
|
||||
return []ProfileState{
|
||||
ProfileStatePending,
|
||||
ProfileStateActive,
|
||||
ProfileStateInactive,
|
||||
ProfileStateDeactivated,
|
||||
}
|
||||
}
|
||||
|
||||
func (v ProfileState) IsValid() bool {
|
||||
switch v {
|
||||
case
|
||||
ProfileStatePending,
|
||||
ProfileStateActive,
|
||||
ProfileStateInactive:
|
||||
ProfileStateDeactivated:
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -74,3 +82,24 @@ func (v *ProfileState) UnmarshalText(text []byte) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (states ProfileStateValues) Value() (driver.Value, error) {
|
||||
if len(states) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
result.WriteString("{")
|
||||
|
||||
for i, state := range states {
|
||||
if i > 0 {
|
||||
result.WriteString(",")
|
||||
}
|
||||
|
||||
fmt.Fprintf(&result, "%q", state.String())
|
||||
}
|
||||
|
||||
result.WriteString("}")
|
||||
|
||||
return result.String(), nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user