backbone_pos/infrastructure/persistence/pos_manager_pin_repository.rs
1//! Repository for PosManagerPin entities
2//!
3//! Originally generated by metaphor-schema; now **user-owned** — this exact path is declared under
4//! `user_owned` in `metaphor.codegen.yaml`, so the generator skips it wholesale. The custom methods
5//! below hold the hand-written credential SQL (4-layer rule: services orchestrate, repos hold SQL).
6//!
7//! The stored value is an ARGON2 HASH, never the PIN. These statements never SELECT the raw secret
8//! into anything but the verify path's one read, and the DTO layer strips the hash from every
9//! generated response surface (see `pos_manager_pin_dto.rs`).
10//!
11//! Tenancy is composition-installed (ADR-0029): pool reads/writes ride the ambient org scope's
12//! request-dedicated connection (the fence is the whole guard — a credential another unit owns is
13//! simply absent); the upsert takes the CALLER'S open, scope-relayed transaction so the
14//! first-PIN-exists proof and the write it gates commit together.
15
16use sqlx::{PgPool, Row};
17use uuid::Uuid;
18
19use backbone_orm::org_scope;
20
21use crate::domain::entity::PosManagerPin;
22
23/// Table name for PosManagerPin entities
24pub const TABLE_NAME: &str = "pos.pos_manager_pins";
25
26/// Repository for PosManagerPin entities.
27///
28/// All standard CRUD, soft-delete, pagination, and bulk methods are
29/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
30pub struct PosManagerPinRepository(
31 backbone_orm::GenericCrudRepository<PosManagerPin, backbone_orm::SoftDelete>,
32);
33
34impl std::ops::Deref for PosManagerPinRepository {
35 type Target = backbone_orm::GenericCrudRepository<PosManagerPin, backbone_orm::SoftDelete>;
36 fn deref(&self) -> &Self::Target { &self.0 }
37}
38
39impl PosManagerPinRepository {
40 /// Create a new repository instance.
41 pub fn new(pool: PgPool) -> Self {
42 Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
43 }
44}
45
46/// A live credential as the verify path reads it: the hash to check against plus the lockout state.
47/// Read by manager — one live PIN per manager per unit (partial-unique re-declared by the composer).
48pub struct PinCredentialRow {
49 pub id: Uuid,
50 pub pin_hash: String,
51 pub failed_attempts: i32,
52 pub locked_until: Option<chrono::DateTime<chrono::Utc>>,
53}
54
55/// Hand-written credential SQL. Lives here (not in the write service) per the module's 4-layer rule.
56/// The ambient org scope's fence is the whole tenancy guard: every statement below is ID-only.
57impl PosManagerPinRepository {
58 /// Write (or replace) a manager's PIN hash, clearing any lockout and failure history — setting a
59 /// new PIN is the administrative unlock. Idempotent per manager within the caller's scope.
60 ///
61 /// The upsert ARBITRATES IN-TRANSACTION rather than via ON CONFLICT: the composer re-declares the
62 /// per-unit live-credential unique with an org-leading arbiter, and a company-led legacy arbiter
63 /// no longer exists to target. Taking `SELECT ... FOR UPDATE` on the live row serializes two
64 /// concurrent `set_pin` calls on the actual credential; the INSERT arm then cannot race. Takes
65 /// the CALLER'S connection so the first-PIN bootstrap proof and this write commit as one unit;
66 /// the caller has already relayed the ambient scope onto it.
67 pub async fn upsert_hash_on(
68 &self,
69 conn: &mut sqlx::PgConnection,
70 employee_party_id: Uuid,
71 pin_hash: &str,
72 now: chrono::DateTime<chrono::Utc>,
73 source_ip: Option<&str>,
74 ) -> Result<(), sqlx::Error> {
75 let existing = sqlx::query(
76 r#"SELECT id FROM pos.pos_manager_pins
77 WHERE employee_party_id=$1 AND (metadata->>'deleted_at') IS NULL
78 FOR UPDATE"#,
79 )
80 .bind(employee_party_id)
81 .fetch_optional(&mut *conn)
82 .await?;
83 if let Some(row) = existing {
84 sqlx::query(
85 r#"UPDATE pos.pos_manager_pins SET
86 pin_hash=$2, failed_attempts=0, locked_until=NULL,
87 last_attempt_at=$3, last_attempt_ip=$4
88 WHERE id=$1"#,
89 )
90 .bind(row.get::<Uuid, _>("id")).bind(pin_hash).bind(now).bind(source_ip)
91 .execute(conn)
92 .await?;
93 } else {
94 sqlx::query(
95 r#"INSERT INTO pos.pos_manager_pins
96 (id, employee_party_id, pin_hash, failed_attempts, locked_until, last_attempt_at, last_attempt_ip)
97 VALUES ($1,$2,$3,0,NULL,$4,$5)"#,
98 )
99 .bind(Uuid::new_v4()).bind(employee_party_id).bind(pin_hash).bind(now).bind(source_ip)
100 .execute(conn)
101 .await?;
102 }
103 Ok(())
104 }
105
106 /// Read a manager's live credential for verification. `Ok(None)` = no PIN set (or the credential
107 /// lives outside the caller's scope). ID-only: rides the ambient org scope.
108 pub async fn fetch_credential(
109 &self,
110 pool: &PgPool,
111 employee_party_id: Uuid,
112 ) -> Result<Option<PinCredentialRow>, sqlx::Error> {
113 let row = org_scope::fetch_optional_row_scoped(
114 pool,
115 sqlx::query(
116 r#"SELECT id, pin_hash, failed_attempts, locked_until FROM pos.pos_manager_pins
117 WHERE employee_party_id=$1 AND (metadata->>'deleted_at') IS NULL"#,
118 )
119 .bind(employee_party_id),
120 )
121 .await?;
122 Ok(row.map(|r| PinCredentialRow {
123 id: r.get("id"),
124 pin_hash: r.get("pin_hash"),
125 failed_attempts: r.get("failed_attempts"),
126 locked_until: r.get("locked_until"),
127 }))
128 }
129
130 /// Whether ANY live PIN exists in the caller's scope — the bootstrap gate for the first
131 /// `set_pin` (before any credential exists, the first set is allowed without other proof; after
132 /// that, changing a PIN requires the current one or another verified manager). Runs on the
133 /// caller's scope-relayed transaction so the proof and the write it gates see the same world.
134 pub async fn any_live_pin_exists_on(
135 &self,
136 conn: &mut sqlx::PgConnection,
137 ) -> Result<bool, sqlx::Error> {
138 let n: i64 = sqlx::query_scalar(
139 "SELECT COUNT(*) FROM pos.pos_manager_pins WHERE (metadata->>'deleted_at') IS NULL",
140 )
141 .fetch_one(conn)
142 .await?;
143 Ok(n > 0)
144 }
145
146 /// Record a FAILED verification: bump the consecutive-failure counter and lock when it crosses
147 /// `max_attempts` (the service passes the computed unlock instant). Self-contained increment —
148 /// concurrent failures each land, the counter never goes backwards. ID-only: rides the ambient
149 /// org scope.
150 pub async fn record_failure(
151 &self,
152 pool: &PgPool,
153 id: Uuid,
154 max_attempts: u32,
155 lockout_until: chrono::DateTime<chrono::Utc>,
156 now: chrono::DateTime<chrono::Utc>,
157 source_ip: Option<&str>,
158 ) -> Result<(), sqlx::Error> {
159 org_scope::execute_scoped(
160 pool,
161 sqlx::query(
162 r#"UPDATE pos.pos_manager_pins SET
163 failed_attempts = failed_attempts + 1,
164 locked_until = CASE WHEN failed_attempts + 1 >= $2 THEN $3 ELSE locked_until END,
165 last_attempt_at = $4,
166 last_attempt_ip = $5
167 WHERE id=$1"#,
168 )
169 .bind(id).bind(max_attempts as i32).bind(lockout_until).bind(now).bind(source_ip),
170 )
171 .await?;
172 Ok(())
173 }
174
175 /// Record a SUCCESSFUL verification: clear the failure counter and any lockout. ID-only: rides
176 /// the ambient org scope.
177 pub async fn record_success(
178 &self,
179 pool: &PgPool,
180 id: Uuid,
181 now: chrono::DateTime<chrono::Utc>,
182 source_ip: Option<&str>,
183 ) -> Result<(), sqlx::Error> {
184 org_scope::execute_scoped(
185 pool,
186 sqlx::query(
187 "UPDATE pos.pos_manager_pins SET failed_attempts=0, locked_until=NULL, last_attempt_at=$2, last_attempt_ip=$3 WHERE id=$1",
188 )
189 .bind(id).bind(now).bind(source_ip),
190 )
191 .await?;
192 Ok(())
193 }
194}
195
196backbone_core::impl_crud_repository!(PosManagerPinRepository, PosManagerPin, soft_delete);