Skip to content

Commit 5ed13e3

Browse files
authored
feat(guard): create soft delete for organizations (#213)
1 parent 051676e commit 5ed13e3

File tree

8 files changed

+108
-22
lines changed

8 files changed

+108
-22
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class AddDeletedAtForOrganizations < ActiveRecord::Migration[5.1]
2+
def change
3+
add_column :organizations, :deleted_at, :datetime, null: true, default: nil
4+
end
5+
end

guard/lib/guard/events/organization_deleted.ex

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,17 @@ defmodule Guard.Events.OrganizationDeleted do
33
Event emitted when an organization is deleted.
44
"""
55

6-
@spec publish(String.t()) :: :ok
7-
def publish(organization_id) do
6+
@spec publish(String.t(), Keyword.t()) :: :ok | {:error, :missing_event_type}
7+
def publish(organization_id, opts) do
8+
case opts[:type] do
9+
:soft_delete -> do_publish(organization_id, "soft_deleted")
10+
:hard_delete -> do_publish(organization_id, "deleted")
11+
nil -> {:error, :missing_event_type}
12+
_ -> {:error, :invalid_event_type}
13+
end
14+
end
15+
16+
defp do_publish(organization_id, routing_key) do
817
event =
918
InternalApi.Organization.OrganizationDeleted.new(
1019
org_id: organization_id,
@@ -14,10 +23,7 @@ defmodule Guard.Events.OrganizationDeleted do
1423

1524
message = InternalApi.Organization.OrganizationDeleted.encode(event)
1625

17-
exchange_name = "organization_exchange"
18-
routing_key = "deleted"
19-
2026
{:ok, channel} = AMQP.Application.get_channel(:organization)
21-
:ok = AMQP.Basic.publish(channel, exchange_name, routing_key, message)
27+
:ok = AMQP.Basic.publish(channel, "organization_exchange", routing_key, message)
2228
end
2329
end

guard/lib/guard/front_repo/organization.ex

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ defmodule Guard.FrontRepo.Organization do
1717
field(:deny_member_workflows, :boolean)
1818
field(:deny_non_member_workflows, :boolean)
1919
field(:settings, :map)
20+
field(:deleted_at, :utc_datetime)
2021

2122
has_many(:contacts, Guard.FrontRepo.OrganizationContact, on_delete: :delete_all)
2223
has_many(:suspensions, Guard.FrontRepo.OrganizationSuspension, on_delete: :delete_all)
@@ -47,7 +48,8 @@ defmodule Guard.FrontRepo.Organization do
4748
:deny_member_workflows,
4849
:deny_non_member_workflows,
4950
:settings,
50-
:created_at
51+
:created_at,
52+
:deleted_at
5153
],
5254
empty_values: []
5355
)

guard/lib/guard/grpc_servers/organization_server.ex

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -421,10 +421,8 @@ defmodule Guard.GrpcServers.OrganizationServer do
421421
observe("destroy", fn ->
422422
case fetch_organization(org_id, "") do
423423
{:ok, organization} ->
424-
case Guard.Store.Organization.destroy(organization) do
424+
case Guard.Store.Organization.soft_destroy(organization) do
425425
{:ok, _} ->
426-
Guard.Events.OrganizationDeleted.publish(organization.id)
427-
428426
%Google.Protobuf.Empty{}
429427

430428
{:error, changeset} ->

guard/lib/guard/store/organization.ex

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ defmodule Guard.Store.Organization do
88
def get_by_id(id) when is_binary(id) and id != "" do
99
Guard.FrontRepo.Organization
1010
|> where([o], o.id == ^id)
11+
|> where_undeleted()
1112
|> Guard.FrontRepo.one()
1213
|> case do
1314
nil -> Util.ToTuple.error("Organization '#{id}' not found.", :not_found)
@@ -20,6 +21,7 @@ defmodule Guard.Store.Organization do
2021
def get_by_username(username) when is_binary(username) and username != "" do
2122
Guard.FrontRepo.Organization
2223
|> where([o], o.username == ^username)
24+
|> where_undeleted()
2325
|> Guard.FrontRepo.one()
2426
|> case do
2527
nil -> Util.ToTuple.error("Organization '#{username}' not found.", :not_found)
@@ -30,6 +32,7 @@ defmodule Guard.Store.Organization do
3032
def exists?(org_id) do
3133
Guard.FrontRepo.Organization
3234
|> where([o], o.id == ^org_id)
35+
|> where_undeleted()
3336
|> Guard.FrontRepo.exists?()
3437
end
3538

@@ -70,6 +73,7 @@ defmodule Guard.Store.Organization do
7073
def list(params, keyset_params) do
7174
query =
7275
Guard.FrontRepo.Organization
76+
|> where_undeleted()
7377
|> filter_by_created_at_gt(params.created_at_gt)
7478

7579
flop =
@@ -87,6 +91,7 @@ defmodule Guard.Store.Organization do
8791
def list_by_ids(ids) when is_list(ids) do
8892
Guard.FrontRepo.Organization
8993
|> where([o], o.id in ^ids)
94+
|> where_undeleted()
9095
|> Guard.FrontRepo.all()
9196
end
9297

@@ -296,12 +301,47 @@ defmodule Guard.Store.Organization do
296301
end
297302

298303
@doc """
299-
Deletes an organization.
304+
Soft deletes an organization.
300305
Returns {:ok, organization} if successful, or {:error, changeset} if not.
301306
"""
302-
@spec destroy(Guard.FrontRepo.Organization.t()) ::
307+
@spec soft_destroy(Guard.FrontRepo.Organization.t()) ::
303308
{:ok, Guard.FrontRepo.Organization.t()} | {:error, Ecto.Changeset.t()}
304-
def destroy(%Guard.FrontRepo.Organization{} = organization) do
305-
Guard.FrontRepo.delete(organization)
309+
def soft_destroy(%Guard.FrontRepo.Organization{} = organization) do
310+
result =
311+
organization
312+
|> Guard.FrontRepo.Organization.changeset(%{deleted_at: DateTime.utc_now()})
313+
|> Guard.FrontRepo.update()
314+
315+
case result do
316+
{:ok, _} ->
317+
:ok = Guard.Events.OrganizationDeleted.publish(organization.id, type: :soft_delete)
318+
result
319+
320+
_ ->
321+
result
322+
end
323+
end
324+
325+
@doc """
326+
Deletes an organization completely from the database.
327+
Returns {:ok, organization} if successful, or {:error, changeset} if not.
328+
"""
329+
@spec hard_destroy(Guard.FrontRepo.Organization.t()) ::
330+
{:ok, Guard.FrontRepo.Organization.t()} | {:error, Ecto.Changeset.t()}
331+
def hard_destroy(%Guard.FrontRepo.Organization{} = organization) do
332+
result = Guard.FrontRepo.delete(organization)
333+
334+
case result do
335+
{:ok, _} ->
336+
:ok = Guard.Events.OrganizationDeleted.publish(organization.id, type: :hard_delete)
337+
result
338+
339+
_ ->
340+
result
341+
end
342+
end
343+
344+
defp where_undeleted(query) do
345+
query |> where([o], is_nil(o.deleted_at))
306346
end
307347
end
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
defmodule Guard.FrontRepo.Migrations.AddDeletedAtForOrganizations do
2+
use Ecto.Migration
3+
4+
def change do
5+
alter table(:organizations) do
6+
add :deleted_at, :utc_datetime, null: true, default: nil
7+
end
8+
end
9+
end

guard/test/guard/grpc_servers/organization_server_test.exs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1196,26 +1196,35 @@ defmodule Guard.GrpcServers.OrganizationServerTest do
11961196
grpc_channel: channel,
11971197
organization: organization
11981198
} do
1199-
with_mocks [{Guard.Events.OrganizationDeleted, [], [publish: fn _ -> :ok end]}] do
1199+
with_mocks [{Guard.Events.OrganizationDeleted, [], [publish: fn _, _ -> :ok end]}] do
12001200
request = Organization.DestroyRequest.new(org_id: organization.id)
12011201

12021202
{:ok, _} = channel |> Organization.OrganizationService.Stub.destroy(request)
12031203

1204-
# Verify organization was deleted
1205-
assert is_nil(Guard.FrontRepo.get(Guard.FrontRepo.Organization, organization.id))
1204+
# Verify organization was soft deleted
1205+
soft_deleted_org = Guard.FrontRepo.get(Guard.FrontRepo.Organization, organization.id)
1206+
assert soft_deleted_org.deleted_at != nil
12061207

1207-
assert_called(Guard.Events.OrganizationDeleted.publish(organization.id))
1208+
assert {:error, {:not_found, _message}} =
1209+
Guard.Store.Organization.get_by_id(soft_deleted_org.id)
1210+
1211+
assert {:error, {:not_found, _message}} =
1212+
Guard.Store.Organization.get_by_username(soft_deleted_org.username)
1213+
1214+
assert_called(
1215+
Guard.Events.OrganizationDeleted.publish(organization.id, type: :soft_delete)
1216+
)
12081217
end
12091218
end
12101219

12111220
test "returns error for non-existent organization", %{grpc_channel: channel} do
1212-
with_mocks [{Guard.Events.OrganizationDeleted, [], [publish: fn _ -> :ok end]}] do
1221+
with_mocks [{Guard.Events.OrganizationDeleted, [], [publish: fn _, _ -> :ok end]}] do
12131222
non_existent_id = Ecto.UUID.generate()
12141223
request = Organization.DestroyRequest.new(org_id: non_existent_id)
12151224

12161225
assert {:error, %GRPC.RPCError{message: message}} = Stub.destroy(channel, request)
12171226
assert message =~ "Organization '#{non_existent_id}' not found."
1218-
assert_not_called(Guard.Events.OrganizationDeleted.publish(:_))
1227+
assert_not_called(Guard.Events.OrganizationDeleted.publish(:_, type: :soft_delete))
12191228
end
12201229
end
12211230
end

guard/test/guard/store/organization_test.exs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -538,14 +538,14 @@ defmodule Guard.Store.OrganizationTest do
538538
end
539539
end
540540

541-
describe "destroy/1" do
541+
describe "hard_destroy/1" do
542542
test "deletes organization when it exists", %{org_id: org_id} do
543543
organization = Guard.FrontRepo.get!(Guard.FrontRepo.Organization, org_id)
544544

545545
contact = Support.Factories.Organization.insert_contact!(organization.id)
546546
suspension = Support.Factories.Organization.insert_suspension!(organization.id)
547547

548-
assert {:ok, deleted_org} = Organization.destroy(organization)
548+
assert {:ok, deleted_org} = Organization.hard_destroy(organization)
549549
assert deleted_org.id == org_id
550550

551551
assert is_nil(Guard.FrontRepo.get(Guard.FrontRepo.Organization, org_id))
@@ -554,6 +554,23 @@ defmodule Guard.Store.OrganizationTest do
554554
end
555555
end
556556

557+
describe "soft_destroy/1" do
558+
test "marks an organization as deleted", %{org_id: org_id} do
559+
organization = Guard.FrontRepo.get!(Guard.FrontRepo.Organization, org_id)
560+
561+
assert {:ok, deleted_org} = Organization.soft_destroy(organization)
562+
assert deleted_org.id == org_id
563+
564+
soft_deleted_org = Guard.FrontRepo.get(Guard.FrontRepo.Organization, org_id)
565+
assert soft_deleted_org.deleted_at != nil
566+
567+
assert {:error, {:not_found, _message}} = Organization.get_by_id(org_id)
568+
569+
assert {:error, {:not_found, _message}} =
570+
Organization.get_by_username(soft_deleted_org.username)
571+
end
572+
end
573+
557574
describe "create/1" do
558575
test "creates organization with valid attributes" do
559576
owner_id = Ecto.UUID.generate()

0 commit comments

Comments
 (0)