From 3e5689ea96d3d80944d7efad930e5b65be7e4dd2 Mon Sep 17 00:00:00 2001 From: Cramsan Date: Sat, 25 Jul 2026 23:59:43 -0700 Subject: [PATCH 1/5] [EDIFIKANA] Mark DS-OCC-008 fixed in datastore test catalog (NONE) --- edifikana/back-end/docs/datastore-test-catalog.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/edifikana/back-end/docs/datastore-test-catalog.md b/edifikana/back-end/docs/datastore-test-catalog.md index 95358d830..bf6ad0c36 100644 --- a/edifikana/back-end/docs/datastore-test-catalog.md +++ b/edifikana/back-end/docs/datastore-test-catalog.md @@ -372,7 +372,10 @@ datastore query bodies, and mapper functions side by side — not assumptions fr | DS-OCC-006 | P1 | `updateOccupant(isPrimary = false)` on a currently-primary occupant | Confirms the boolean-null-skip distinction (§3) — must actually flip to `false`, not skip | | DS-OCC-007 | P1 | `softRemoveOccupant` sets both `status = INACTIVE` and `end_date = today` atomically in one update | Confirm both fields change together; occupant remains readable via `getOccupant` (only `deleted_at` gates that, not `status`) but is excluded from `listOccupantsForUnit(includeInactive = false)` | | DS-OCC-008 | P2 | `purgeOccupant` on an active (non-deleted, non-removed) occupant | Fixed — guard now refuses (returns `false`), matching the consistent group. Also added `deleteOccupant` (Occupant previously had no way to soft-delete at all) | +<<<<<<< HEAD | DS-OCC-009 ⚠️ | P2 | `purgeOccupant` on a **nonexistent** id | **NOT YET IMPLEMENTED** — no test exercises this case today. Expected: `Result.success(false)`, no error (zero rows matched by the guard's `select()`), same shape as every other consistent-group resource's nonexistent-id purge case. Surfaced as a coverage gap while verifying `DS-OCC-008`'s fix — mirrors `DS-EMP-010`/`DS-EVT-013`, the analogous gaps for `updateEmployee`/`updateEventLogEntry` | +======= +>>>>>>> 907dd089f ([EDIFIKANA] Mark DS-OCC-008 fixed in datastore test catalog (NONE)) ### 4.8 `SupabaseOrganizationDatastore` (tables: `organizations`, `user_organization_mapping`) From 7e45806bddfba141f28960a4288c8aeec2499120 Mon Sep 17 00:00:00 2001 From: Cramsan Date: Sun, 26 Jul 2026 00:37:04 -0700 Subject: [PATCH 2/5] [EDIFIKANA] Mark DS-ORG-010 and DS-X-005 fixed in datastore test catalog (NONE) --- edifikana/back-end/docs/datastore-test-catalog.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/edifikana/back-end/docs/datastore-test-catalog.md b/edifikana/back-end/docs/datastore-test-catalog.md index bf6ad0c36..95358d830 100644 --- a/edifikana/back-end/docs/datastore-test-catalog.md +++ b/edifikana/back-end/docs/datastore-test-catalog.md @@ -372,10 +372,7 @@ datastore query bodies, and mapper functions side by side — not assumptions fr | DS-OCC-006 | P1 | `updateOccupant(isPrimary = false)` on a currently-primary occupant | Confirms the boolean-null-skip distinction (§3) — must actually flip to `false`, not skip | | DS-OCC-007 | P1 | `softRemoveOccupant` sets both `status = INACTIVE` and `end_date = today` atomically in one update | Confirm both fields change together; occupant remains readable via `getOccupant` (only `deleted_at` gates that, not `status`) but is excluded from `listOccupantsForUnit(includeInactive = false)` | | DS-OCC-008 | P2 | `purgeOccupant` on an active (non-deleted, non-removed) occupant | Fixed — guard now refuses (returns `false`), matching the consistent group. Also added `deleteOccupant` (Occupant previously had no way to soft-delete at all) | -<<<<<<< HEAD | DS-OCC-009 ⚠️ | P2 | `purgeOccupant` on a **nonexistent** id | **NOT YET IMPLEMENTED** — no test exercises this case today. Expected: `Result.success(false)`, no error (zero rows matched by the guard's `select()`), same shape as every other consistent-group resource's nonexistent-id purge case. Surfaced as a coverage gap while verifying `DS-OCC-008`'s fix — mirrors `DS-EMP-010`/`DS-EVT-013`, the analogous gaps for `updateEmployee`/`updateEventLogEntry` | -======= ->>>>>>> 907dd089f ([EDIFIKANA] Mark DS-OCC-008 fixed in datastore test catalog (NONE)) ### 4.8 `SupabaseOrganizationDatastore` (tables: `organizations`, `user_organization_mapping`) From 0388395f0562c66a0fdf23a53dca4d36cdbc6d06 Mon Sep 17 00:00:00 2001 From: Cramsan Date: Sun, 26 Jul 2026 18:17:21 -0700 Subject: [PATCH 3/5] [EDIFIKANA] Add purgeDocument hard-delete method (NONE) DocumentDatastore had no way to hard-delete a document at all, unlike every other resource, so integration test fixtures accumulated as soft-deleted rows forever. Adds purgeDocument (select-then-guard-then-delete, same shape as every other purgeX method) and updates the shared integration test teardown to actually purge document fixtures instead of just soft-deleting them. --- .../supabase/SupabaseIntegrationTest.kt | 3 +- .../server/datastore/DocumentDatastore.kt | 8 +++++ .../supabase/SupabaseDocumentDatastore.kt | 33 +++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseIntegrationTest.kt b/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseIntegrationTest.kt index cb1967edf..357fdb56f 100644 --- a/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseIntegrationTest.kt +++ b/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseIntegrationTest.kt @@ -477,9 +477,8 @@ abstract class SupabaseIntegrationTest : KoinTest { results += notificationDatastore.purgeNotification(it) } documentResources.forEach { - // No purgeDocument exists (see DS-DOC-007) — soft-delete is the only - // cleanup available; document rows accumulate as soft-deleted after tests. results += documentDatastore.deleteDocument(it) + results += documentDatastore.purgeDocument(it) } invitationResources.forEach { results += membershipDatastore.cancelInvite(it) diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/DocumentDatastore.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/DocumentDatastore.kt index b7ba24705..e9b976662 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/DocumentDatastore.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/DocumentDatastore.kt @@ -57,4 +57,12 @@ interface DocumentDatastore { * Soft-deletes the document with the given [documentId]. Returns true if the record was deleted. */ suspend fun deleteDocument(documentId: DocumentId): Result + + /** + * Permanently deletes a soft-deleted document record by [documentId]. + * Only purges records that are already soft-deleted. This is intended for testing and + * maintenance purposes only. + * Returns the [Result] of the operation with a [Boolean] indicating if the record was purged. + */ + suspend fun purgeDocument(documentId: DocumentId): Result } diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseDocumentDatastore.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseDocumentDatastore.kt index 2e21b8728..685b68923 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseDocumentDatastore.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseDocumentDatastore.kt @@ -147,6 +147,39 @@ class SupabaseDocumentDatastore(private val postgrest: Postgrest, private val cl }.decodeSingleOrNull() != null } + /** + * Permanently deletes a soft-deleted document by [documentId]. Returns true if successful. + * Only purges records that are already soft-deleted (deletedAt is not null). + */ + + override suspend fun purgeDocument(documentId: DocumentId): Result = + runSuspendCatching(TAG) { + logD(TAG, "Purging document: %s", documentId) + + // First verify the record exists and is soft-deleted + val entity = + postgrest + .from(DocumentEntity.COLLECTION) + .select { + filter { + DocumentEntity::documentId eq documentId.documentId + } + }.decodeSingleOrNull() + + // Only purge if it exists and is soft-deleted + if (entity?.deletedAt == null) { + return@runSuspendCatching false + } + + // Delete the record + postgrest.from(DocumentEntity.COLLECTION).delete { + filter { + DocumentEntity::documentId eq documentId.documentId + } + } + true + } + companion object { const val TAG = "SupabaseDocumentDatastore" } From f9fa1b99d3ca18f2be5537f4b2124135e0766ee1 Mon Sep 17 00:00:00 2001 From: Cramsan Date: Sun, 26 Jul 2026 18:17:25 -0700 Subject: [PATCH 4/5] [EDIFIKANA] Add DS-DOC-007/DS-DOC-007b purgeDocument tests (NONE) --- ...upabaseDocumentDatastoreIntegrationTest.kt | 58 +++++++++++++++++-- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseDocumentDatastoreIntegrationTest.kt b/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseDocumentDatastoreIntegrationTest.kt index 6b9fba1c6..090e4d4fd 100644 --- a/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseDocumentDatastoreIntegrationTest.kt +++ b/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseDocumentDatastoreIntegrationTest.kt @@ -9,6 +9,7 @@ import com.cramsan.edifikana.lib.model.property.PropertyId import com.cramsan.edifikana.lib.model.unit.UnitId import com.cramsan.edifikana.lib.model.user.UserId import com.cramsan.framework.utils.uuid.UUID +import io.github.jan.supabase.postgrest.postgrest import kotlinx.coroutines.runBlocking import kotlin.test.BeforeTest import kotlin.test.Test @@ -19,11 +20,6 @@ import kotlin.test.assertTrue /** * Covers datastore-test-catalog.md §4.2 (DS-DOC-001..007). - * - * DS-DOC-007 (no `purgeDocument` method exists) is confirmed by static inspection of - * `DocumentDatastore`/`SupabaseDocumentDatastore` — there is no such method to call, so there is - * no runtime test for it. `SupabaseIntegrationTest.tearDown()` only soft-deletes document - * fixtures created via `registerDocumentForDeletion`; the rows remain, soft-deleted, forever. */ class SupabaseDocumentDatastoreIntegrationTest : SupabaseIntegrationTest() { @@ -273,6 +269,58 @@ class SupabaseDocumentDatastoreIntegrationTest : SupabaseIntegrationTest() { assertNull(getResult.getOrNull()) } + @Test + fun `DS-DOC-007 purgeDocument should refuse to purge an active (non-deleted) document`(): Unit = runBlocking { + // Arrange + val created = documentDatastore.createDocument( + orgId = orgId!!, + propertyId = null, + unitId = null, + filename = "${testPrefix}_active_purge_attempt.pdf", + mimeType = MimeType("application/pdf"), + documentType = DocumentType.PHOTO, + assetId = AssetId("documents/${testPrefix}_active_purge_attempt.pdf"), + createdBy = null, + ).registerDocumentForDeletion().getOrThrow() + + // Act - purge without a prior soft-delete; Document is now in the consistent guarded group + val purgeResult = documentDatastore.purgeDocument(created.id) + + // Assert + assertTrue(purgeResult.isSuccess) + assertTrue(purgeResult.getOrNull() != true) + assertNotNull(documentDatastore.getDocument(created.id).getOrNull()) + } + + @Test + fun `DS-DOC-007b purgeDocument should permanently remove a soft-deleted document`(): Unit = runBlocking { + // Arrange + val created = documentDatastore.createDocument( + orgId = orgId!!, + propertyId = null, + unitId = null, + filename = "${testPrefix}_to_purge.pdf", + mimeType = MimeType("application/pdf"), + documentType = DocumentType.PHOTO, + assetId = AssetId("documents/${testPrefix}_to_purge.pdf"), + createdBy = null, + ).registerDocumentForDeletion().getOrThrow() + assertTrue(documentDatastore.deleteDocument(created.id).getOrThrow()) + + // Act + val purgeResult = documentDatastore.purgeDocument(created.id) + + // Assert - succeeds, and the row is genuinely gone (not just soft-deleted) - a raw, + // untyped select is required here since getDocument alone can't distinguish "gone" from + // "still soft-deleted," both return null. + assertTrue(purgeResult.isSuccess) + assertTrue(purgeResult.getOrNull() == true) + val remainingRows = supabase.postgrest.from("documents").select { + filter { eq("document_id", created.id.documentId) } + }.decodeList() + assertTrue(remainingRows.isEmpty()) + } + @Test fun `getDocument should return null for nonexistent id`(): Unit = runBlocking { // Arrange From 200683830a558f1733feb9fe21687c00cf6a16f8 Mon Sep 17 00:00:00 2001 From: Cramsan Date: Sun, 26 Jul 2026 18:17:28 -0700 Subject: [PATCH 5/5] [EDIFIKANA] Mark DS-DOC-007 fixed and close out Finding #5 (NONE) --- edifikana/back-end/docs/datastore-test-catalog.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/edifikana/back-end/docs/datastore-test-catalog.md b/edifikana/back-end/docs/datastore-test-catalog.md index 95358d830..1355e7417 100644 --- a/edifikana/back-end/docs/datastore-test-catalog.md +++ b/edifikana/back-end/docs/datastore-test-catalog.md @@ -153,8 +153,15 @@ datastore query bodies, and mapper functions side by side — not assumptions fr **no `purgeDocument` method at all**. Confirm there is genuinely no way to hard-delete a document via the datastore layer (check whether `SupabaseIntegrationTest`'s cleanup path for documents falls back to something else, or leaks rows across test runs). → **DS-DOC-007** - (`DS-CA-011`, `DS-OCC-008`, `DS-PAY-007`, `DS-RENT-006`, `DS-TASK-009` fixed — this closes out - Finding #5 except for `DS-DOC-007`) + + **Fixed:** added `purgeDocument` (select-then-guard-then-delete, same shape as every other + `purgeX` method) to `DocumentDatastore`/`SupabaseDocumentDatastore`. `SupabaseIntegrationTest`'s + `documentResources` teardown now calls `deleteDocument` then `purgeDocument`, matching every + other resource type, instead of leaving soft-deleted rows to accumulate forever. Confirmed + live: `DS-DOC-007` (refuses to purge an active document) and `DS-DOC-007b` (a soft-deleted + document is genuinely gone afterward, confirmed via a raw untyped select) both pass. + (`DS-CA-011`, `DS-OCC-008`, `DS-PAY-007`, `DS-RENT-006`, `DS-TASK-009`, `DS-DOC-007` all + fixed — this closes out Finding #5 in full) 6. **`ON DELETE` behavior for property/unit children is inconsistent — half `CASCADE`, half `RESTRICT` — and none of it fires today because every "delete" the app performs is a soft `UPDATE`, not a real `DELETE`.** Confirmed via migrations: `units.property_id`, @@ -294,7 +301,7 @@ datastore query bodies, and mapper functions side by side — not assumptions fr | DS-DOC-004 | P1 | `getDocuments` for an org with documents belonging to a **different** org mixed in the table | Only the requested org's documents returned — scoping correctness at the query level (this is the sole authorization backstop for `DOC-005`/`DOC-011` in the API catalog, since there's no separate RBAC check inside the datastore) | | DS-DOC-005 | P1 | `updateDocument(filename = ..., documentType = null)` and vice versa | Independent partial updates both work | | DS-DOC-006 | P0 | `deleteDocument` then re-`getDocument` | Soft-delete confirmed via read-side filter | -| DS-DOC-007 | **P0** | Confirm no `purgeDocument` exists | Finding #5 — document rows can never be hard-deleted through this datastore; check `SupabaseIntegrationTest`'s cleanup registration for documents to see what it does instead (likely nothing, meaning integration test runs accumulate orphaned document rows over time) | +| DS-DOC-007 | **P0** | Confirm no `purgeDocument` exists | Fixed — `purgeDocument` added (consistent select-then-guard-then-delete pattern); `DS-DOC-007b` confirms a soft-deleted document is genuinely hard-deleted; integration test teardown updated to purge instead of leak | ### 4.3 `SupabaseEmployeeDatastore` (table: `employee`; view: `v_user_employees`)