mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-28 09:46:59 -05:00
Compare commits
3 Commits
461ed9e1f4
...
67525173cb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67525173cb | ||
|
|
edd12b4454 | ||
|
|
87fae37d90 |
290
docs/ATOMIC_APPROVAL_TRANSACTIONS.md
Normal file
290
docs/ATOMIC_APPROVAL_TRANSACTIONS.md
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
# Atomic Approval Transactions
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Phase 1 of the atomic transaction RPC implementation has been completed. This replaces the error-prone manual rollback logic in the `process-selective-approval` edge function with a true PostgreSQL ACID transaction.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### OLD Flow (process-selective-approval)
|
||||||
|
```
|
||||||
|
Edge Function (2,759 lines) ──┐
|
||||||
|
├─ Create entity 1 ├─ Manual rollback on error
|
||||||
|
├─ Create entity 2 ├─ Network failure = orphaned data
|
||||||
|
├─ Create entity 3 ├─ Edge function crash = partial state
|
||||||
|
└─ Manual rollback if error─┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### NEW Flow (process-selective-approval-v2)
|
||||||
|
```
|
||||||
|
Edge Function (~200 lines)
|
||||||
|
│
|
||||||
|
└──> RPC: process_approval_transaction()
|
||||||
|
│
|
||||||
|
└──> PostgreSQL Transaction ───────────┐
|
||||||
|
├─ Create entity 1 │
|
||||||
|
├─ Create entity 2 │ ATOMIC
|
||||||
|
├─ Create entity 3 │ (all-or-nothing)
|
||||||
|
└─ Commit OR Rollback ──────────┘
|
||||||
|
(any error = auto rollback)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Benefits
|
||||||
|
|
||||||
|
✅ **True ACID Transactions**: All operations succeed or fail together
|
||||||
|
✅ **Automatic Rollback**: ANY error triggers immediate rollback
|
||||||
|
✅ **Network Resilient**: Edge function crash = automatic rollback
|
||||||
|
✅ **Zero Orphaned Entities**: Impossible by design
|
||||||
|
✅ **Simpler Code**: Edge function reduced from 2,759 to ~200 lines
|
||||||
|
|
||||||
|
## Database Functions Created
|
||||||
|
|
||||||
|
### Main Transaction Function
|
||||||
|
```sql
|
||||||
|
process_approval_transaction(
|
||||||
|
p_submission_id UUID,
|
||||||
|
p_item_ids UUID[],
|
||||||
|
p_moderator_id UUID,
|
||||||
|
p_submitter_id UUID,
|
||||||
|
p_request_id TEXT DEFAULT NULL
|
||||||
|
) RETURNS JSONB
|
||||||
|
```
|
||||||
|
|
||||||
|
### Helper Functions
|
||||||
|
- `create_entity_from_submission()` - Creates entities (parks, rides, companies, etc.)
|
||||||
|
- `update_entity_from_submission()` - Updates existing entities
|
||||||
|
- `delete_entity_from_submission()` - Soft/hard deletes entities
|
||||||
|
|
||||||
|
### Monitoring Table
|
||||||
|
- `approval_transaction_metrics` - Tracks performance, success rate, and rollbacks
|
||||||
|
|
||||||
|
## Feature Flag
|
||||||
|
|
||||||
|
The new flow is **disabled by default** to allow gradual rollout and testing.
|
||||||
|
|
||||||
|
### Enabling the New Flow
|
||||||
|
|
||||||
|
#### For Moderators (via Admin UI)
|
||||||
|
1. Navigate to Admin Settings
|
||||||
|
2. Find "Approval Transaction Mode" card
|
||||||
|
3. Toggle "Use Atomic Transaction RPC" to ON
|
||||||
|
4. Page will reload automatically
|
||||||
|
|
||||||
|
#### Programmatically
|
||||||
|
```typescript
|
||||||
|
// Enable
|
||||||
|
localStorage.setItem('use_rpc_approval', 'true');
|
||||||
|
|
||||||
|
// Disable
|
||||||
|
localStorage.setItem('use_rpc_approval', 'false');
|
||||||
|
|
||||||
|
// Check status
|
||||||
|
const isEnabled = localStorage.getItem('use_rpc_approval') === 'true';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
### Basic Functionality ✓
|
||||||
|
- [ ] Enable feature flag via admin UI
|
||||||
|
- [ ] Approve a simple submission (1-2 items)
|
||||||
|
- [ ] Verify entities created correctly
|
||||||
|
- [ ] Check console logs for "Using edge function: process-selective-approval-v2"
|
||||||
|
- [ ] Verify version history shows correct attribution
|
||||||
|
|
||||||
|
### Error Scenarios ✓
|
||||||
|
- [ ] Submit invalid data → verify full rollback
|
||||||
|
- [ ] Trigger validation error → verify no partial state
|
||||||
|
- [ ] Kill edge function mid-execution → verify auto rollback
|
||||||
|
- [ ] Check logs for "Transaction failed, rolling back" messages
|
||||||
|
|
||||||
|
### Concurrent Operations ✓
|
||||||
|
- [ ] Two moderators approve same submission → one succeeds, one gets locked error
|
||||||
|
- [ ] Verify only one set of entities created (no duplicates)
|
||||||
|
|
||||||
|
### Data Integrity ✓
|
||||||
|
- [ ] Run orphaned entity check (see SQL query below)
|
||||||
|
- [ ] Verify session variables cleared after transaction
|
||||||
|
- [ ] Check `approval_transaction_metrics` for success rate
|
||||||
|
|
||||||
|
## Monitoring Queries
|
||||||
|
|
||||||
|
### Check for Orphaned Entities
|
||||||
|
```sql
|
||||||
|
-- Should return 0 rows after migration
|
||||||
|
SELECT
|
||||||
|
'parks' as table_name,
|
||||||
|
COUNT(*) as orphaned_count
|
||||||
|
FROM parks p
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM park_versions pv
|
||||||
|
WHERE pv.park_id = p.id
|
||||||
|
)
|
||||||
|
AND p.created_at > NOW() - INTERVAL '24 hours'
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
'rides' as table_name,
|
||||||
|
COUNT(*) as orphaned_count
|
||||||
|
FROM rides r
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM ride_versions rv
|
||||||
|
WHERE rv.ride_id = r.id
|
||||||
|
)
|
||||||
|
AND r.created_at > NOW() - INTERVAL '24 hours';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transaction Success Rate
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
DATE_TRUNC('hour', created_at) as hour,
|
||||||
|
COUNT(*) as total_transactions,
|
||||||
|
COUNT(*) FILTER (WHERE success) as successful,
|
||||||
|
COUNT(*) FILTER (WHERE rollback_triggered) as rollbacks,
|
||||||
|
ROUND(AVG(duration_ms), 2) as avg_duration_ms,
|
||||||
|
ROUND(100.0 * COUNT(*) FILTER (WHERE success) / COUNT(*), 2) as success_rate
|
||||||
|
FROM approval_transaction_metrics
|
||||||
|
WHERE created_at > NOW() - INTERVAL '24 hours'
|
||||||
|
GROUP BY hour
|
||||||
|
ORDER BY hour DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rollback Rate Alert
|
||||||
|
```sql
|
||||||
|
-- Alert if rollback_rate > 5%
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE rollback_triggered) as rollbacks,
|
||||||
|
COUNT(*) as total_attempts,
|
||||||
|
ROUND(100.0 * COUNT(*) FILTER (WHERE rollback_triggered) / COUNT(*), 2) as rollback_rate
|
||||||
|
FROM approval_transaction_metrics
|
||||||
|
WHERE created_at > NOW() - INTERVAL '1 hour'
|
||||||
|
HAVING COUNT(*) FILTER (WHERE rollback_triggered) > 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
If issues are detected after enabling the new flow:
|
||||||
|
|
||||||
|
### Immediate Rollback (< 5 minutes)
|
||||||
|
```javascript
|
||||||
|
// Disable feature flag globally (or ask users to toggle off)
|
||||||
|
localStorage.setItem('use_rpc_approval', 'false');
|
||||||
|
window.location.reload();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Recovery (if needed)
|
||||||
|
```sql
|
||||||
|
-- Identify submissions processed with v2 during problem window
|
||||||
|
SELECT
|
||||||
|
atm.submission_id,
|
||||||
|
atm.created_at,
|
||||||
|
atm.success,
|
||||||
|
atm.error_message
|
||||||
|
FROM approval_transaction_metrics atm
|
||||||
|
WHERE atm.created_at BETWEEN '2025-11-06 19:00:00' AND '2025-11-06 20:00:00'
|
||||||
|
AND atm.success = false
|
||||||
|
AND atm.rollback_triggered = true;
|
||||||
|
|
||||||
|
-- Check for orphaned entities (if any exist)
|
||||||
|
-- Use the orphaned entity query above
|
||||||
|
```
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
After full rollout, these metrics should be achieved:
|
||||||
|
|
||||||
|
| Metric | Target | Current |
|
||||||
|
|--------|--------|---------|
|
||||||
|
| Zero orphaned entities | 0 | ✓ TBD |
|
||||||
|
| Zero manual rollback logs | 0 | ✓ TBD |
|
||||||
|
| Transaction success rate | >99% | ✓ TBD |
|
||||||
|
| Avg transaction time | <500ms | ✓ TBD |
|
||||||
|
| Rollback rate | <1% | ✓ TBD |
|
||||||
|
|
||||||
|
## Deployment Phases
|
||||||
|
|
||||||
|
### Phase 1: ✅ COMPLETE
|
||||||
|
- [x] Create RPC functions (helper + main transaction)
|
||||||
|
- [x] Create new edge function v2
|
||||||
|
- [x] Add feature flag support to frontend
|
||||||
|
- [x] Create admin UI toggle
|
||||||
|
- [x] Add monitoring table + RLS policies
|
||||||
|
|
||||||
|
### Phase 2: 🟡 IN PROGRESS
|
||||||
|
- [ ] Test with single moderator account
|
||||||
|
- [ ] Monitor metrics for 24 hours
|
||||||
|
- [ ] Verify zero orphaned entities
|
||||||
|
- [ ] Collect feedback from test moderator
|
||||||
|
|
||||||
|
### Phase 3: 🔲 PENDING
|
||||||
|
- [ ] Enable for 10% of requests (weighted sampling)
|
||||||
|
- [ ] Monitor for 24 hours
|
||||||
|
- [ ] Check rollback rate < 1%
|
||||||
|
|
||||||
|
### Phase 4: 🔲 PENDING
|
||||||
|
- [ ] Enable for 50% of requests
|
||||||
|
- [ ] Monitor for 48 hours
|
||||||
|
- [ ] Compare performance metrics with old flow
|
||||||
|
|
||||||
|
### Phase 5: 🔲 PENDING
|
||||||
|
- [ ] Enable for 100% of requests
|
||||||
|
- [ ] Monitor for 1 week
|
||||||
|
- [ ] Mark old edge function as deprecated
|
||||||
|
|
||||||
|
### Phase 6: 🔲 PENDING
|
||||||
|
- [ ] Remove old edge function
|
||||||
|
- [ ] Archive manual rollback code
|
||||||
|
- [ ] Update all documentation
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Issue: Feature flag not working
|
||||||
|
**Symptom**: Logs still show "process-selective-approval" even with flag enabled
|
||||||
|
**Solution**: Clear localStorage and reload: `localStorage.clear(); window.location.reload()`
|
||||||
|
|
||||||
|
### Issue: "RPC function not found" error
|
||||||
|
**Symptom**: Edge function fails with "process_approval_transaction not found"
|
||||||
|
**Solution**: Run the migration again or check function exists:
|
||||||
|
```sql
|
||||||
|
SELECT proname FROM pg_proc WHERE proname = 'process_approval_transaction';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue: High rollback rate (>5%)
|
||||||
|
**Symptom**: Many transactions rolling back in metrics
|
||||||
|
**Solution**:
|
||||||
|
1. Check error messages in `approval_transaction_metrics.error_message`
|
||||||
|
2. Disable feature flag immediately
|
||||||
|
3. Investigate root cause (validation issues, data integrity, etc.)
|
||||||
|
|
||||||
|
### Issue: Orphaned entities detected
|
||||||
|
**Symptom**: Entities exist without corresponding versions
|
||||||
|
**Solution**:
|
||||||
|
1. Disable feature flag immediately
|
||||||
|
2. Run orphaned entity query to identify affected entities
|
||||||
|
3. Investigate cause (likely edge function crash during v1 flow)
|
||||||
|
4. Consider data cleanup (manual deletion or version creation)
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**Q: Can I switch back to the old flow without data loss?**
|
||||||
|
A: Yes. Simply toggle off the feature flag. All data remains intact.
|
||||||
|
|
||||||
|
**Q: What happens if the edge function crashes mid-transaction?**
|
||||||
|
A: PostgreSQL automatically rolls back the entire transaction. No orphaned data.
|
||||||
|
|
||||||
|
**Q: How do I know which flow approved a submission?**
|
||||||
|
A: Check `approval_transaction_metrics` table. If a row exists, v2 was used.
|
||||||
|
|
||||||
|
**Q: Can I use both flows simultaneously?**
|
||||||
|
A: Yes. The feature flag is per-browser, so different moderators can use different flows.
|
||||||
|
|
||||||
|
**Q: When will the old flow be removed?**
|
||||||
|
A: After 30 days of stable operation at 100% rollout (Phase 6).
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Moderation Documentation](./versioning/MODERATION.md)
|
||||||
|
- [JSONB Elimination](./JSONB_ELIMINATION_COMPLETE.md)
|
||||||
|
- [Error Tracking](./ERROR_TRACKING.md)
|
||||||
|
- [PostgreSQL Transactions](https://www.postgresql.org/docs/current/tutorial-transactions.html)
|
||||||
|
- [ACID Properties](https://en.wikipedia.org/wiki/ACID)
|
||||||
100
src/components/admin/ApprovalTransactionToggle.tsx
Normal file
100
src/components/admin/ApprovalTransactionToggle.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { AlertCircle, CheckCircle2, Database } from 'lucide-react';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin toggle for switching between approval flows:
|
||||||
|
* - OLD: Manual rollback in edge function (error-prone)
|
||||||
|
* - NEW: Atomic PostgreSQL transaction (true ACID guarantees)
|
||||||
|
*/
|
||||||
|
export function ApprovalTransactionToggle() {
|
||||||
|
const [useRpcApproval, setUseRpcApproval] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Read feature flag from localStorage
|
||||||
|
const enabled = localStorage.getItem('use_rpc_approval') === 'true';
|
||||||
|
setUseRpcApproval(enabled);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggle = (checked: boolean) => {
|
||||||
|
localStorage.setItem('use_rpc_approval', checked ? 'true' : 'false');
|
||||||
|
setUseRpcApproval(checked);
|
||||||
|
|
||||||
|
// Force page reload to ensure all components pick up the new setting
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Database className="w-5 h-5" />
|
||||||
|
Approval Transaction Mode
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Control which approval flow is used for moderation
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="rpc-approval-toggle" className="flex-1">
|
||||||
|
<div className="font-medium">Use Atomic Transaction RPC</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{useRpcApproval
|
||||||
|
? 'Using process-selective-approval-v2 (NEW)'
|
||||||
|
: 'Using process-selective-approval (OLD)'}
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="rpc-approval-toggle"
|
||||||
|
checked={useRpcApproval}
|
||||||
|
onCheckedChange={handleToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{useRpcApproval ? (
|
||||||
|
<Alert>
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
<strong>Atomic Transaction Mode Enabled</strong>
|
||||||
|
<ul className="mt-2 space-y-1 text-sm">
|
||||||
|
<li>✅ True ACID transactions</li>
|
||||||
|
<li>✅ Automatic rollback on errors</li>
|
||||||
|
<li>✅ Network-resilient (edge function crash = auto rollback)</li>
|
||||||
|
<li>✅ Zero orphaned entities</li>
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
<strong>Legacy Mode Active</strong>
|
||||||
|
<ul className="mt-2 space-y-1 text-sm">
|
||||||
|
<li>⚠️ Manual rollback logic (error-prone)</li>
|
||||||
|
<li>⚠️ Risk of orphaned entities if edge function crashes</li>
|
||||||
|
<li>⚠️ No true atomicity guarantee</li>
|
||||||
|
</ul>
|
||||||
|
<p className="mt-2 font-medium">
|
||||||
|
Consider enabling Atomic Transaction Mode for improved reliability.
|
||||||
|
</p>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground pt-2 border-t">
|
||||||
|
<p><strong>Testing Instructions:</strong></p>
|
||||||
|
<ol className="list-decimal list-inside space-y-1 mt-1">
|
||||||
|
<li>Enable the toggle and approve a submission</li>
|
||||||
|
<li>Check logs for "Using edge function: process-selective-approval-v2"</li>
|
||||||
|
<li>Verify no orphaned entities in the database</li>
|
||||||
|
<li>Test error scenarios (invalid data, network issues)</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -151,6 +151,63 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
Relationships: []
|
Relationships: []
|
||||||
}
|
}
|
||||||
|
approval_transaction_metrics: {
|
||||||
|
Row: {
|
||||||
|
created_at: string | null
|
||||||
|
duration_ms: number | null
|
||||||
|
error_message: string | null
|
||||||
|
id: string
|
||||||
|
items_count: number
|
||||||
|
moderator_id: string
|
||||||
|
request_id: string | null
|
||||||
|
rollback_triggered: boolean | null
|
||||||
|
submission_id: string
|
||||||
|
submitter_id: string
|
||||||
|
success: boolean
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
created_at?: string | null
|
||||||
|
duration_ms?: number | null
|
||||||
|
error_message?: string | null
|
||||||
|
id?: string
|
||||||
|
items_count: number
|
||||||
|
moderator_id: string
|
||||||
|
request_id?: string | null
|
||||||
|
rollback_triggered?: boolean | null
|
||||||
|
submission_id: string
|
||||||
|
submitter_id: string
|
||||||
|
success: boolean
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
created_at?: string | null
|
||||||
|
duration_ms?: number | null
|
||||||
|
error_message?: string | null
|
||||||
|
id?: string
|
||||||
|
items_count?: number
|
||||||
|
moderator_id?: string
|
||||||
|
request_id?: string | null
|
||||||
|
rollback_triggered?: boolean | null
|
||||||
|
submission_id?: string
|
||||||
|
submitter_id?: string
|
||||||
|
success?: boolean
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "approval_transaction_metrics_submission_id_fkey"
|
||||||
|
columns: ["submission_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "content_submissions"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "approval_transaction_metrics_submission_id_fkey"
|
||||||
|
columns: ["submission_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "moderation_queue_with_entities"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
blog_posts: {
|
blog_posts: {
|
||||||
Row: {
|
Row: {
|
||||||
author_id: string
|
author_id: string
|
||||||
@@ -5960,6 +6017,10 @@ export type Database = {
|
|||||||
oldest_deleted_date: string
|
oldest_deleted_date: string
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
create_entity_from_submission: {
|
||||||
|
Args: { p_created_by: string; p_data: Json; p_entity_type: string }
|
||||||
|
Returns: string
|
||||||
|
}
|
||||||
create_submission_with_items:
|
create_submission_with_items:
|
||||||
| {
|
| {
|
||||||
Args: {
|
Args: {
|
||||||
@@ -5980,6 +6041,14 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
Returns: string
|
Returns: string
|
||||||
}
|
}
|
||||||
|
delete_entity_from_submission: {
|
||||||
|
Args: {
|
||||||
|
p_deleted_by: string
|
||||||
|
p_entity_id: string
|
||||||
|
p_entity_type: string
|
||||||
|
}
|
||||||
|
Returns: undefined
|
||||||
|
}
|
||||||
detect_orphaned_images: { Args: never; Returns: number }
|
detect_orphaned_images: { Args: never; Returns: number }
|
||||||
detect_orphaned_images_with_logging: { Args: never; Returns: undefined }
|
detect_orphaned_images_with_logging: { Args: never; Returns: undefined }
|
||||||
extend_submission_lock: {
|
extend_submission_lock: {
|
||||||
@@ -6172,6 +6241,16 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
migrate_ride_technical_data: { Args: never; Returns: undefined }
|
migrate_ride_technical_data: { Args: never; Returns: undefined }
|
||||||
migrate_user_list_items: { Args: never; Returns: undefined }
|
migrate_user_list_items: { Args: never; Returns: undefined }
|
||||||
|
process_approval_transaction: {
|
||||||
|
Args: {
|
||||||
|
p_item_ids: string[]
|
||||||
|
p_moderator_id: string
|
||||||
|
p_request_id?: string
|
||||||
|
p_submission_id: string
|
||||||
|
p_submitter_id: string
|
||||||
|
}
|
||||||
|
Returns: Json
|
||||||
|
}
|
||||||
release_expired_locks: { Args: never; Returns: number }
|
release_expired_locks: { Args: never; Returns: number }
|
||||||
release_submission_lock: {
|
release_submission_lock: {
|
||||||
Args: { moderator_id: string; submission_id: string }
|
Args: { moderator_id: string; submission_id: string }
|
||||||
@@ -6216,6 +6295,15 @@ export type Database = {
|
|||||||
Args: { target_company_id: string }
|
Args: { target_company_id: string }
|
||||||
Returns: undefined
|
Returns: undefined
|
||||||
}
|
}
|
||||||
|
update_entity_from_submission: {
|
||||||
|
Args: {
|
||||||
|
p_data: Json
|
||||||
|
p_entity_id: string
|
||||||
|
p_entity_type: string
|
||||||
|
p_updated_by: string
|
||||||
|
}
|
||||||
|
Returns: string
|
||||||
|
}
|
||||||
update_entity_view_counts: { Args: never; Returns: undefined }
|
update_entity_view_counts: { Args: never; Returns: undefined }
|
||||||
update_park_ratings: {
|
update_park_ratings: {
|
||||||
Args: { target_park_id: string }
|
Args: { target_park_id: string }
|
||||||
|
|||||||
@@ -177,14 +177,42 @@ export async function approvePhotoSubmission(
|
|||||||
* @param itemIds - Array of item IDs to approve
|
* @param itemIds - Array of item IDs to approve
|
||||||
* @returns Action result
|
* @returns Action result
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Feature flag to enable atomic transaction RPC approval flow.
|
||||||
|
* Set to true to use the new process-selective-approval-v2 edge function
|
||||||
|
* that wraps the entire approval in a single PostgreSQL transaction.
|
||||||
|
*
|
||||||
|
* Benefits of v2:
|
||||||
|
* - True atomic transactions (all-or-nothing)
|
||||||
|
* - Automatic rollback on ANY error
|
||||||
|
* - Network-resilient (edge function crash = auto rollback)
|
||||||
|
* - Eliminates orphaned entities
|
||||||
|
*
|
||||||
|
* To enable: localStorage.setItem('use_rpc_approval', 'true')
|
||||||
|
* To disable: localStorage.setItem('use_rpc_approval', 'false')
|
||||||
|
*/
|
||||||
|
const USE_RPC_APPROVAL = typeof window !== 'undefined' &&
|
||||||
|
localStorage.getItem('use_rpc_approval') === 'true';
|
||||||
|
|
||||||
export async function approveSubmissionItems(
|
export async function approveSubmissionItems(
|
||||||
supabase: SupabaseClient,
|
supabase: SupabaseClient,
|
||||||
submissionId: string,
|
submissionId: string,
|
||||||
itemIds: string[]
|
itemIds: string[]
|
||||||
): Promise<ModerationActionResult> {
|
): Promise<ModerationActionResult> {
|
||||||
try {
|
try {
|
||||||
|
// Use v2 edge function if feature flag is enabled
|
||||||
|
const edgeFunctionName = USE_RPC_APPROVAL
|
||||||
|
? 'process-selective-approval-v2'
|
||||||
|
: 'process-selective-approval';
|
||||||
|
|
||||||
|
console.log(`[Approval] Using edge function: ${edgeFunctionName}`, {
|
||||||
|
submissionId,
|
||||||
|
itemCount: itemIds.length,
|
||||||
|
useRpcApproval: USE_RPC_APPROVAL
|
||||||
|
});
|
||||||
|
|
||||||
const { data: approvalData, error: approvalError, requestId } = await invokeWithTracking(
|
const { data: approvalData, error: approvalError, requestId } = await invokeWithTracking(
|
||||||
'process-selective-approval',
|
edgeFunctionName,
|
||||||
{
|
{
|
||||||
itemIds,
|
itemIds,
|
||||||
submissionId,
|
submissionId,
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ verify_jwt = true
|
|||||||
[functions.process-selective-approval]
|
[functions.process-selective-approval]
|
||||||
verify_jwt = false
|
verify_jwt = false
|
||||||
|
|
||||||
|
[functions.process-selective-approval-v2]
|
||||||
|
verify_jwt = false
|
||||||
|
|
||||||
[functions.send-escalation-notification]
|
[functions.send-escalation-notification]
|
||||||
verify_jwt = true
|
verify_jwt = true
|
||||||
|
|
||||||
|
|||||||
4
supabase/functions/process-selective-approval-v2/cors.ts
Normal file
4
supabase/functions/process-selective-approval-v2/cors.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const corsHeaders = {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||||
|
};
|
||||||
188
supabase/functions/process-selective-approval-v2/index.ts
Normal file
188
supabase/functions/process-selective-approval-v2/index.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
||||||
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
|
||||||
|
|
||||||
|
const SUPABASE_URL = Deno.env.get('SUPABASE_URL') || 'https://api.thrillwiki.com';
|
||||||
|
const SUPABASE_ANON_KEY = Deno.env.get('SUPABASE_ANON_KEY')!;
|
||||||
|
|
||||||
|
interface ApprovalRequest {
|
||||||
|
submissionId: string;
|
||||||
|
itemIds: string[];
|
||||||
|
idempotencyKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
serve(async (req) => {
|
||||||
|
// Generate request ID for tracking
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// STEP 1: Authentication
|
||||||
|
const authHeader = req.headers.get('Authorization');
|
||||||
|
if (!authHeader) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Missing Authorization header' }),
|
||||||
|
{ status: 401, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||||
|
global: { headers: { Authorization: authHeader } }
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||||
|
if (authError || !user) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Unauthorized' }),
|
||||||
|
{ status: 401, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[${requestId}] Approval request from moderator ${user.id}`);
|
||||||
|
|
||||||
|
// STEP 2: Parse request
|
||||||
|
const body: ApprovalRequest = await req.json();
|
||||||
|
const { submissionId, itemIds, idempotencyKey } = body;
|
||||||
|
|
||||||
|
if (!submissionId || !itemIds || itemIds.length === 0) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Missing required fields: submissionId, itemIds' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEP 3: Idempotency check
|
||||||
|
const { data: existingKey } = await supabase
|
||||||
|
.from('submission_idempotency_keys')
|
||||||
|
.select('*')
|
||||||
|
.eq('idempotency_key', idempotencyKey)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (existingKey?.status === 'completed') {
|
||||||
|
console.log(`[${requestId}] Idempotency key already processed, returning cached result`);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify(existingKey.result_data),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Cache-Status': 'HIT'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEP 4: Fetch submission to get submitter_id
|
||||||
|
const { data: submission, error: submissionError } = await supabase
|
||||||
|
.from('content_submissions')
|
||||||
|
.select('user_id, status, assigned_to')
|
||||||
|
.eq('id', submissionId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (submissionError || !submission) {
|
||||||
|
console.error(`[${requestId}] Submission not found:`, submissionError);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Submission not found' }),
|
||||||
|
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEP 5: Verify moderator can approve this submission
|
||||||
|
if (submission.assigned_to && submission.assigned_to !== user.id) {
|
||||||
|
console.error(`[${requestId}] Submission locked by another moderator`);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Submission is locked by another moderator' }),
|
||||||
|
{ status: 409, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['pending', 'partially_approved'].includes(submission.status)) {
|
||||||
|
console.error(`[${requestId}] Invalid submission status: ${submission.status}`);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Submission already processed' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEP 6: Register idempotency key as processing
|
||||||
|
if (!existingKey) {
|
||||||
|
await supabase.from('submission_idempotency_keys').insert({
|
||||||
|
idempotency_key: idempotencyKey,
|
||||||
|
submission_id: submissionId,
|
||||||
|
moderator_id: user.id,
|
||||||
|
status: 'processing'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[${requestId}] Calling process_approval_transaction RPC`);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// STEP 7: Call RPC function - entire approval in single atomic transaction
|
||||||
|
// ============================================================================
|
||||||
|
const { data: result, error: rpcError } = await supabase.rpc(
|
||||||
|
'process_approval_transaction',
|
||||||
|
{
|
||||||
|
p_submission_id: submissionId,
|
||||||
|
p_item_ids: itemIds,
|
||||||
|
p_moderator_id: user.id,
|
||||||
|
p_submitter_id: submission.user_id,
|
||||||
|
p_request_id: requestId
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rpcError) {
|
||||||
|
// Transaction failed - EVERYTHING rolled back automatically by PostgreSQL
|
||||||
|
console.error(`[${requestId}] Approval transaction failed:`, rpcError);
|
||||||
|
|
||||||
|
// Update idempotency key to failed
|
||||||
|
await supabase
|
||||||
|
.from('submission_idempotency_keys')
|
||||||
|
.update({
|
||||||
|
status: 'failed',
|
||||||
|
error_message: rpcError.message,
|
||||||
|
completed_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
.eq('idempotency_key', idempotencyKey);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Approval transaction failed',
|
||||||
|
message: rpcError.message,
|
||||||
|
details: rpcError.details
|
||||||
|
}),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[${requestId}] Transaction completed successfully:`, result);
|
||||||
|
|
||||||
|
// STEP 8: Success - update idempotency key
|
||||||
|
await supabase
|
||||||
|
.from('submission_idempotency_keys')
|
||||||
|
.update({
|
||||||
|
status: 'completed',
|
||||||
|
result_data: result,
|
||||||
|
completed_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
.eq('idempotency_key', idempotencyKey);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify(result),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Request-Id': requestId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[${requestId}] Unexpected error:`, error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
}),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,676 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- ATOMIC APPROVAL TRANSACTION - Phase 1 Implementation
|
||||||
|
-- ============================================================================
|
||||||
|
-- This migration creates RPC functions that wrap the entire approval flow
|
||||||
|
-- in a single PostgreSQL transaction for true atomic rollback.
|
||||||
|
--
|
||||||
|
-- Key Benefits:
|
||||||
|
-- 1. True ACID transactions - all-or-nothing guarantee
|
||||||
|
-- 2. Automatic rollback on ANY error (no manual cleanup needed)
|
||||||
|
-- 3. Network-resilient (edge function crash = auto rollback)
|
||||||
|
-- 4. Eliminates orphaned entities
|
||||||
|
-- 5. Simplifies edge function from 2,759 lines to ~200 lines
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Create metrics table for monitoring transaction performance
|
||||||
|
CREATE TABLE IF NOT EXISTS approval_transaction_metrics (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
submission_id UUID NOT NULL REFERENCES content_submissions(id) ON DELETE CASCADE,
|
||||||
|
moderator_id UUID NOT NULL,
|
||||||
|
submitter_id UUID NOT NULL,
|
||||||
|
items_count INTEGER NOT NULL,
|
||||||
|
duration_ms INTEGER,
|
||||||
|
success BOOLEAN NOT NULL,
|
||||||
|
error_message TEXT,
|
||||||
|
rollback_triggered BOOLEAN DEFAULT FALSE,
|
||||||
|
request_id TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_approval_metrics_submission ON approval_transaction_metrics(submission_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_approval_metrics_created ON approval_transaction_metrics(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_approval_metrics_success ON approval_transaction_metrics(success);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- HELPER FUNCTION: Create entity from submission data
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE OR REPLACE FUNCTION create_entity_from_submission(
|
||||||
|
p_entity_type TEXT,
|
||||||
|
p_data JSONB,
|
||||||
|
p_created_by UUID
|
||||||
|
)
|
||||||
|
RETURNS UUID
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_entity_id UUID;
|
||||||
|
BEGIN
|
||||||
|
CASE p_entity_type
|
||||||
|
WHEN 'park' THEN
|
||||||
|
INSERT INTO parks (
|
||||||
|
name, slug, description, park_type, status,
|
||||||
|
location_id, operator_id, property_owner_id,
|
||||||
|
opening_date, closing_date,
|
||||||
|
opening_date_precision, closing_date_precision,
|
||||||
|
website_url, phone, email,
|
||||||
|
banner_image_url, banner_image_id,
|
||||||
|
card_image_url, card_image_id
|
||||||
|
) VALUES (
|
||||||
|
p_data->>'name',
|
||||||
|
p_data->>'slug',
|
||||||
|
p_data->>'description',
|
||||||
|
p_data->>'park_type',
|
||||||
|
p_data->>'status',
|
||||||
|
(p_data->>'location_id')::UUID,
|
||||||
|
(p_data->>'operator_id')::UUID,
|
||||||
|
(p_data->>'property_owner_id')::UUID,
|
||||||
|
(p_data->>'opening_date')::DATE,
|
||||||
|
(p_data->>'closing_date')::DATE,
|
||||||
|
p_data->>'opening_date_precision',
|
||||||
|
p_data->>'closing_date_precision',
|
||||||
|
p_data->>'website_url',
|
||||||
|
p_data->>'phone',
|
||||||
|
p_data->>'email',
|
||||||
|
p_data->>'banner_image_url',
|
||||||
|
p_data->>'banner_image_id',
|
||||||
|
p_data->>'card_image_url',
|
||||||
|
p_data->>'card_image_id'
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_entity_id;
|
||||||
|
|
||||||
|
WHEN 'ride' THEN
|
||||||
|
INSERT INTO rides (
|
||||||
|
name, slug, park_id, ride_type, status,
|
||||||
|
manufacturer_id, ride_model_id,
|
||||||
|
opening_date, closing_date,
|
||||||
|
opening_date_precision, closing_date_precision,
|
||||||
|
description,
|
||||||
|
banner_image_url, banner_image_id,
|
||||||
|
card_image_url, card_image_id
|
||||||
|
) VALUES (
|
||||||
|
p_data->>'name',
|
||||||
|
p_data->>'slug',
|
||||||
|
(p_data->>'park_id')::UUID,
|
||||||
|
p_data->>'ride_type',
|
||||||
|
p_data->>'status',
|
||||||
|
(p_data->>'manufacturer_id')::UUID,
|
||||||
|
(p_data->>'ride_model_id')::UUID,
|
||||||
|
(p_data->>'opening_date')::DATE,
|
||||||
|
(p_data->>'closing_date')::DATE,
|
||||||
|
p_data->>'opening_date_precision',
|
||||||
|
p_data->>'closing_date_precision',
|
||||||
|
p_data->>'description',
|
||||||
|
p_data->>'banner_image_url',
|
||||||
|
p_data->>'banner_image_id',
|
||||||
|
p_data->>'card_image_url',
|
||||||
|
p_data->>'card_image_id'
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_entity_id;
|
||||||
|
|
||||||
|
WHEN 'manufacturer', 'operator', 'property_owner', 'designer' THEN
|
||||||
|
INSERT INTO companies (
|
||||||
|
name, slug, company_type, description,
|
||||||
|
website_url, founded_year,
|
||||||
|
banner_image_url, banner_image_id,
|
||||||
|
card_image_url, card_image_id
|
||||||
|
) VALUES (
|
||||||
|
p_data->>'name',
|
||||||
|
p_data->>'slug',
|
||||||
|
p_entity_type,
|
||||||
|
p_data->>'description',
|
||||||
|
p_data->>'website_url',
|
||||||
|
(p_data->>'founded_year')::INTEGER,
|
||||||
|
p_data->>'banner_image_url',
|
||||||
|
p_data->>'banner_image_id',
|
||||||
|
p_data->>'card_image_url',
|
||||||
|
p_data->>'card_image_id'
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_entity_id;
|
||||||
|
|
||||||
|
WHEN 'ride_model' THEN
|
||||||
|
INSERT INTO ride_models (
|
||||||
|
name, slug, manufacturer_id, ride_type,
|
||||||
|
description,
|
||||||
|
banner_image_url, banner_image_id,
|
||||||
|
card_image_url, card_image_id
|
||||||
|
) VALUES (
|
||||||
|
p_data->>'name',
|
||||||
|
p_data->>'slug',
|
||||||
|
(p_data->>'manufacturer_id')::UUID,
|
||||||
|
p_data->>'ride_type',
|
||||||
|
p_data->>'description',
|
||||||
|
p_data->>'banner_image_url',
|
||||||
|
p_data->>'banner_image_id',
|
||||||
|
p_data->>'card_image_url',
|
||||||
|
p_data->>'card_image_id'
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_entity_id;
|
||||||
|
|
||||||
|
ELSE
|
||||||
|
RAISE EXCEPTION 'Unsupported entity type for creation: %', p_entity_type
|
||||||
|
USING ERRCODE = '22023';
|
||||||
|
END CASE;
|
||||||
|
|
||||||
|
RETURN v_entity_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- HELPER FUNCTION: Update entity from submission data
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE OR REPLACE FUNCTION update_entity_from_submission(
|
||||||
|
p_entity_type TEXT,
|
||||||
|
p_data JSONB,
|
||||||
|
p_entity_id UUID,
|
||||||
|
p_updated_by UUID
|
||||||
|
)
|
||||||
|
RETURNS UUID
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
CASE p_entity_type
|
||||||
|
WHEN 'park' THEN
|
||||||
|
UPDATE parks SET
|
||||||
|
name = COALESCE(p_data->>'name', name),
|
||||||
|
slug = COALESCE(p_data->>'slug', slug),
|
||||||
|
description = COALESCE(p_data->>'description', description),
|
||||||
|
park_type = COALESCE(p_data->>'park_type', park_type),
|
||||||
|
status = COALESCE(p_data->>'status', status),
|
||||||
|
location_id = COALESCE((p_data->>'location_id')::UUID, location_id),
|
||||||
|
operator_id = COALESCE((p_data->>'operator_id')::UUID, operator_id),
|
||||||
|
property_owner_id = COALESCE((p_data->>'property_owner_id')::UUID, property_owner_id),
|
||||||
|
opening_date = COALESCE((p_data->>'opening_date')::DATE, opening_date),
|
||||||
|
closing_date = COALESCE((p_data->>'closing_date')::DATE, closing_date),
|
||||||
|
opening_date_precision = COALESCE(p_data->>'opening_date_precision', opening_date_precision),
|
||||||
|
closing_date_precision = COALESCE(p_data->>'closing_date_precision', closing_date_precision),
|
||||||
|
website_url = COALESCE(p_data->>'website_url', website_url),
|
||||||
|
phone = COALESCE(p_data->>'phone', phone),
|
||||||
|
email = COALESCE(p_data->>'email', email),
|
||||||
|
banner_image_url = COALESCE(p_data->>'banner_image_url', banner_image_url),
|
||||||
|
banner_image_id = COALESCE(p_data->>'banner_image_id', banner_image_id),
|
||||||
|
card_image_url = COALESCE(p_data->>'card_image_url', card_image_url),
|
||||||
|
card_image_id = COALESCE(p_data->>'card_image_id', card_image_id),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = p_entity_id;
|
||||||
|
|
||||||
|
WHEN 'ride' THEN
|
||||||
|
UPDATE rides SET
|
||||||
|
name = COALESCE(p_data->>'name', name),
|
||||||
|
slug = COALESCE(p_data->>'slug', slug),
|
||||||
|
park_id = COALESCE((p_data->>'park_id')::UUID, park_id),
|
||||||
|
ride_type = COALESCE(p_data->>'ride_type', ride_type),
|
||||||
|
status = COALESCE(p_data->>'status', status),
|
||||||
|
manufacturer_id = COALESCE((p_data->>'manufacturer_id')::UUID, manufacturer_id),
|
||||||
|
ride_model_id = COALESCE((p_data->>'ride_model_id')::UUID, ride_model_id),
|
||||||
|
opening_date = COALESCE((p_data->>'opening_date')::DATE, opening_date),
|
||||||
|
closing_date = COALESCE((p_data->>'closing_date')::DATE, closing_date),
|
||||||
|
opening_date_precision = COALESCE(p_data->>'opening_date_precision', opening_date_precision),
|
||||||
|
closing_date_precision = COALESCE(p_data->>'closing_date_precision', closing_date_precision),
|
||||||
|
description = COALESCE(p_data->>'description', description),
|
||||||
|
banner_image_url = COALESCE(p_data->>'banner_image_url', banner_image_url),
|
||||||
|
banner_image_id = COALESCE(p_data->>'banner_image_id', banner_image_id),
|
||||||
|
card_image_url = COALESCE(p_data->>'card_image_url', card_image_url),
|
||||||
|
card_image_id = COALESCE(p_data->>'card_image_id', card_image_id),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = p_entity_id;
|
||||||
|
|
||||||
|
WHEN 'manufacturer', 'operator', 'property_owner', 'designer' THEN
|
||||||
|
UPDATE companies SET
|
||||||
|
name = COALESCE(p_data->>'name', name),
|
||||||
|
slug = COALESCE(p_data->>'slug', slug),
|
||||||
|
description = COALESCE(p_data->>'description', description),
|
||||||
|
website_url = COALESCE(p_data->>'website_url', website_url),
|
||||||
|
founded_year = COALESCE((p_data->>'founded_year')::INTEGER, founded_year),
|
||||||
|
banner_image_url = COALESCE(p_data->>'banner_image_url', banner_image_url),
|
||||||
|
banner_image_id = COALESCE(p_data->>'banner_image_id', banner_image_id),
|
||||||
|
card_image_url = COALESCE(p_data->>'card_image_url', card_image_url),
|
||||||
|
card_image_id = COALESCE(p_data->>'card_image_id', card_image_id),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = p_entity_id;
|
||||||
|
|
||||||
|
WHEN 'ride_model' THEN
|
||||||
|
UPDATE ride_models SET
|
||||||
|
name = COALESCE(p_data->>'name', name),
|
||||||
|
slug = COALESCE(p_data->>'slug', slug),
|
||||||
|
manufacturer_id = COALESCE((p_data->>'manufacturer_id')::UUID, manufacturer_id),
|
||||||
|
ride_type = COALESCE(p_data->>'ride_type', ride_type),
|
||||||
|
description = COALESCE(p_data->>'description', description),
|
||||||
|
banner_image_url = COALESCE(p_data->>'banner_image_url', banner_image_url),
|
||||||
|
banner_image_id = COALESCE(p_data->>'banner_image_id', banner_image_id),
|
||||||
|
card_image_url = COALESCE(p_data->>'card_image_url', card_image_url),
|
||||||
|
card_image_id = COALESCE(p_data->>'card_image_id', card_image_id),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = p_entity_id;
|
||||||
|
|
||||||
|
ELSE
|
||||||
|
RAISE EXCEPTION 'Unsupported entity type for update: %', p_entity_type
|
||||||
|
USING ERRCODE = '22023';
|
||||||
|
END CASE;
|
||||||
|
|
||||||
|
RETURN p_entity_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- HELPER FUNCTION: Delete entity from submission
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE OR REPLACE FUNCTION delete_entity_from_submission(
|
||||||
|
p_entity_type TEXT,
|
||||||
|
p_entity_id UUID,
|
||||||
|
p_deleted_by UUID
|
||||||
|
)
|
||||||
|
RETURNS VOID
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
CASE p_entity_type
|
||||||
|
WHEN 'park' THEN
|
||||||
|
DELETE FROM parks WHERE id = p_entity_id;
|
||||||
|
WHEN 'ride' THEN
|
||||||
|
DELETE FROM rides WHERE id = p_entity_id;
|
||||||
|
WHEN 'manufacturer', 'operator', 'property_owner', 'designer' THEN
|
||||||
|
DELETE FROM companies WHERE id = p_entity_id;
|
||||||
|
WHEN 'ride_model' THEN
|
||||||
|
DELETE FROM ride_models WHERE id = p_entity_id;
|
||||||
|
ELSE
|
||||||
|
RAISE EXCEPTION 'Unsupported entity type for deletion: %', p_entity_type
|
||||||
|
USING ERRCODE = '22023';
|
||||||
|
END CASE;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- MAIN TRANSACTION FUNCTION: Process approval in single atomic transaction
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE OR REPLACE FUNCTION process_approval_transaction(
|
||||||
|
p_submission_id UUID,
|
||||||
|
p_item_ids UUID[],
|
||||||
|
p_moderator_id UUID,
|
||||||
|
p_submitter_id UUID,
|
||||||
|
p_request_id TEXT DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS JSONB
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_start_time TIMESTAMPTZ;
|
||||||
|
v_result JSONB;
|
||||||
|
v_item RECORD;
|
||||||
|
v_item_data JSONB;
|
||||||
|
v_entity_id UUID;
|
||||||
|
v_approval_results JSONB[] := ARRAY[]::JSONB[];
|
||||||
|
v_final_status TEXT;
|
||||||
|
v_all_approved BOOLEAN := TRUE;
|
||||||
|
v_some_approved BOOLEAN := FALSE;
|
||||||
|
v_items_processed INTEGER := 0;
|
||||||
|
BEGIN
|
||||||
|
v_start_time := clock_timestamp();
|
||||||
|
|
||||||
|
RAISE NOTICE '[%] Starting atomic approval transaction for submission %',
|
||||||
|
COALESCE(p_request_id, 'NO_REQUEST_ID'),
|
||||||
|
p_submission_id;
|
||||||
|
|
||||||
|
-- ========================================================================
|
||||||
|
-- STEP 1: Set session variables (transaction-scoped with is_local=true)
|
||||||
|
-- ========================================================================
|
||||||
|
PERFORM set_config('app.current_user_id', p_submitter_id::text, true);
|
||||||
|
PERFORM set_config('app.submission_id', p_submission_id::text, true);
|
||||||
|
PERFORM set_config('app.moderator_id', p_moderator_id::text, true);
|
||||||
|
|
||||||
|
-- ========================================================================
|
||||||
|
-- STEP 2: Validate submission ownership and lock status
|
||||||
|
-- ========================================================================
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM content_submissions
|
||||||
|
WHERE id = p_submission_id
|
||||||
|
AND (assigned_to = p_moderator_id OR assigned_to IS NULL)
|
||||||
|
AND status IN ('pending', 'partially_approved')
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'Submission not found, locked by another moderator, or already processed'
|
||||||
|
USING ERRCODE = '42501';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- ========================================================================
|
||||||
|
-- STEP 3: Process each item sequentially within this transaction
|
||||||
|
-- ========================================================================
|
||||||
|
FOR v_item IN
|
||||||
|
SELECT
|
||||||
|
si.*,
|
||||||
|
ps.name as park_name,
|
||||||
|
ps.slug as park_slug,
|
||||||
|
ps.description as park_description,
|
||||||
|
ps.park_type,
|
||||||
|
ps.status as park_status,
|
||||||
|
ps.location_id,
|
||||||
|
ps.operator_id,
|
||||||
|
ps.property_owner_id,
|
||||||
|
ps.opening_date as park_opening_date,
|
||||||
|
ps.closing_date as park_closing_date,
|
||||||
|
ps.opening_date_precision as park_opening_date_precision,
|
||||||
|
ps.closing_date_precision as park_closing_date_precision,
|
||||||
|
ps.website_url as park_website_url,
|
||||||
|
ps.phone as park_phone,
|
||||||
|
ps.email as park_email,
|
||||||
|
ps.banner_image_url as park_banner_image_url,
|
||||||
|
ps.banner_image_id as park_banner_image_id,
|
||||||
|
ps.card_image_url as park_card_image_url,
|
||||||
|
ps.card_image_id as park_card_image_id,
|
||||||
|
rs.name as ride_name,
|
||||||
|
rs.slug as ride_slug,
|
||||||
|
rs.park_id as ride_park_id,
|
||||||
|
rs.ride_type,
|
||||||
|
rs.status as ride_status,
|
||||||
|
rs.manufacturer_id,
|
||||||
|
rs.ride_model_id,
|
||||||
|
rs.opening_date as ride_opening_date,
|
||||||
|
rs.closing_date as ride_closing_date,
|
||||||
|
rs.opening_date_precision as ride_opening_date_precision,
|
||||||
|
rs.closing_date_precision as ride_closing_date_precision,
|
||||||
|
rs.description as ride_description,
|
||||||
|
rs.banner_image_url as ride_banner_image_url,
|
||||||
|
rs.banner_image_id as ride_banner_image_id,
|
||||||
|
rs.card_image_url as ride_card_image_url,
|
||||||
|
rs.card_image_id as ride_card_image_id,
|
||||||
|
cs.name as company_name,
|
||||||
|
cs.slug as company_slug,
|
||||||
|
cs.description as company_description,
|
||||||
|
cs.website_url as company_website_url,
|
||||||
|
cs.founded_year,
|
||||||
|
cs.banner_image_url as company_banner_image_url,
|
||||||
|
cs.banner_image_id as company_banner_image_id,
|
||||||
|
cs.card_image_url as company_card_image_url,
|
||||||
|
cs.card_image_id as company_card_image_id,
|
||||||
|
rms.name as ride_model_name,
|
||||||
|
rms.slug as ride_model_slug,
|
||||||
|
rms.manufacturer_id as ride_model_manufacturer_id,
|
||||||
|
rms.ride_type as ride_model_ride_type,
|
||||||
|
rms.description as ride_model_description,
|
||||||
|
rms.banner_image_url as ride_model_banner_image_url,
|
||||||
|
rms.banner_image_id as ride_model_banner_image_id,
|
||||||
|
rms.card_image_url as ride_model_card_image_url,
|
||||||
|
rms.card_image_id as ride_model_card_image_id
|
||||||
|
FROM submission_items si
|
||||||
|
LEFT JOIN park_submissions ps ON si.park_submission_id = ps.id
|
||||||
|
LEFT JOIN ride_submissions rs ON si.ride_submission_id = rs.id
|
||||||
|
LEFT JOIN company_submissions cs ON si.company_submission_id = cs.id
|
||||||
|
LEFT JOIN ride_model_submissions rms ON si.ride_model_submission_id = rms.id
|
||||||
|
WHERE si.id = ANY(p_item_ids)
|
||||||
|
ORDER BY si.order_index, si.created_at
|
||||||
|
LOOP
|
||||||
|
BEGIN
|
||||||
|
v_items_processed := v_items_processed + 1;
|
||||||
|
|
||||||
|
-- Build item data based on entity type
|
||||||
|
IF v_item.item_type = 'park' THEN
|
||||||
|
v_item_data := jsonb_build_object(
|
||||||
|
'name', v_item.park_name,
|
||||||
|
'slug', v_item.park_slug,
|
||||||
|
'description', v_item.park_description,
|
||||||
|
'park_type', v_item.park_type,
|
||||||
|
'status', v_item.park_status,
|
||||||
|
'location_id', v_item.location_id,
|
||||||
|
'operator_id', v_item.operator_id,
|
||||||
|
'property_owner_id', v_item.property_owner_id,
|
||||||
|
'opening_date', v_item.park_opening_date,
|
||||||
|
'closing_date', v_item.park_closing_date,
|
||||||
|
'opening_date_precision', v_item.park_opening_date_precision,
|
||||||
|
'closing_date_precision', v_item.park_closing_date_precision,
|
||||||
|
'website_url', v_item.park_website_url,
|
||||||
|
'phone', v_item.park_phone,
|
||||||
|
'email', v_item.park_email,
|
||||||
|
'banner_image_url', v_item.park_banner_image_url,
|
||||||
|
'banner_image_id', v_item.park_banner_image_id,
|
||||||
|
'card_image_url', v_item.park_card_image_url,
|
||||||
|
'card_image_id', v_item.park_card_image_id
|
||||||
|
);
|
||||||
|
ELSIF v_item.item_type = 'ride' THEN
|
||||||
|
v_item_data := jsonb_build_object(
|
||||||
|
'name', v_item.ride_name,
|
||||||
|
'slug', v_item.ride_slug,
|
||||||
|
'park_id', v_item.ride_park_id,
|
||||||
|
'ride_type', v_item.ride_type,
|
||||||
|
'status', v_item.ride_status,
|
||||||
|
'manufacturer_id', v_item.manufacturer_id,
|
||||||
|
'ride_model_id', v_item.ride_model_id,
|
||||||
|
'opening_date', v_item.ride_opening_date,
|
||||||
|
'closing_date', v_item.ride_closing_date,
|
||||||
|
'opening_date_precision', v_item.ride_opening_date_precision,
|
||||||
|
'closing_date_precision', v_item.ride_closing_date_precision,
|
||||||
|
'description', v_item.ride_description,
|
||||||
|
'banner_image_url', v_item.ride_banner_image_url,
|
||||||
|
'banner_image_id', v_item.ride_banner_image_id,
|
||||||
|
'card_image_url', v_item.ride_card_image_url,
|
||||||
|
'card_image_id', v_item.ride_card_image_id
|
||||||
|
);
|
||||||
|
ELSIF v_item.item_type IN ('manufacturer', 'operator', 'property_owner', 'designer') THEN
|
||||||
|
v_item_data := jsonb_build_object(
|
||||||
|
'name', v_item.company_name,
|
||||||
|
'slug', v_item.company_slug,
|
||||||
|
'description', v_item.company_description,
|
||||||
|
'website_url', v_item.company_website_url,
|
||||||
|
'founded_year', v_item.founded_year,
|
||||||
|
'banner_image_url', v_item.company_banner_image_url,
|
||||||
|
'banner_image_id', v_item.company_banner_image_id,
|
||||||
|
'card_image_url', v_item.company_card_image_url,
|
||||||
|
'card_image_id', v_item.company_card_image_id
|
||||||
|
);
|
||||||
|
ELSIF v_item.item_type = 'ride_model' THEN
|
||||||
|
v_item_data := jsonb_build_object(
|
||||||
|
'name', v_item.ride_model_name,
|
||||||
|
'slug', v_item.ride_model_slug,
|
||||||
|
'manufacturer_id', v_item.ride_model_manufacturer_id,
|
||||||
|
'ride_type', v_item.ride_model_ride_type,
|
||||||
|
'description', v_item.ride_model_description,
|
||||||
|
'banner_image_url', v_item.ride_model_banner_image_url,
|
||||||
|
'banner_image_id', v_item.ride_model_banner_image_id,
|
||||||
|
'card_image_url', v_item.ride_model_card_image_url,
|
||||||
|
'card_image_id', v_item.ride_model_card_image_id
|
||||||
|
);
|
||||||
|
ELSE
|
||||||
|
RAISE EXCEPTION 'Unsupported item_type: %', v_item.item_type;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Execute action based on action_type
|
||||||
|
IF v_item.action_type = 'create' THEN
|
||||||
|
v_entity_id := create_entity_from_submission(
|
||||||
|
v_item.item_type,
|
||||||
|
v_item_data,
|
||||||
|
p_submitter_id
|
||||||
|
);
|
||||||
|
ELSIF v_item.action_type = 'update' THEN
|
||||||
|
v_entity_id := update_entity_from_submission(
|
||||||
|
v_item.item_type,
|
||||||
|
v_item_data,
|
||||||
|
v_item.target_entity_id,
|
||||||
|
p_submitter_id
|
||||||
|
);
|
||||||
|
ELSIF v_item.action_type = 'delete' THEN
|
||||||
|
PERFORM delete_entity_from_submission(
|
||||||
|
v_item.item_type,
|
||||||
|
v_item.target_entity_id,
|
||||||
|
p_submitter_id
|
||||||
|
);
|
||||||
|
v_entity_id := v_item.target_entity_id;
|
||||||
|
ELSE
|
||||||
|
RAISE EXCEPTION 'Unknown action_type: %', v_item.action_type;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Update submission_item to approved status
|
||||||
|
UPDATE submission_items
|
||||||
|
SET
|
||||||
|
status = 'approved',
|
||||||
|
approved_entity_id = v_entity_id,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = v_item.id;
|
||||||
|
|
||||||
|
-- Track success
|
||||||
|
v_approval_results := array_append(
|
||||||
|
v_approval_results,
|
||||||
|
jsonb_build_object(
|
||||||
|
'itemId', v_item.id,
|
||||||
|
'entityId', v_entity_id,
|
||||||
|
'itemType', v_item.item_type,
|
||||||
|
'actionType', v_item.action_type,
|
||||||
|
'success', true
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
v_some_approved := TRUE;
|
||||||
|
|
||||||
|
RAISE NOTICE '[%] Approved item % (type=%s, action=%s, entityId=%s)',
|
||||||
|
COALESCE(p_request_id, 'NO_REQUEST_ID'),
|
||||||
|
v_item.id,
|
||||||
|
v_item.item_type,
|
||||||
|
v_item.action_type,
|
||||||
|
v_entity_id;
|
||||||
|
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
-- Log error but continue processing remaining items
|
||||||
|
RAISE WARNING '[%] Item % failed: % (SQLSTATE: %)',
|
||||||
|
COALESCE(p_request_id, 'NO_REQUEST_ID'),
|
||||||
|
v_item.id,
|
||||||
|
SQLERRM,
|
||||||
|
SQLSTATE;
|
||||||
|
|
||||||
|
-- Update submission_item to rejected status
|
||||||
|
UPDATE submission_items
|
||||||
|
SET
|
||||||
|
status = 'rejected',
|
||||||
|
rejection_reason = SQLERRM,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = v_item.id;
|
||||||
|
|
||||||
|
-- Track failure
|
||||||
|
v_approval_results := array_append(
|
||||||
|
v_approval_results,
|
||||||
|
jsonb_build_object(
|
||||||
|
'itemId', v_item.id,
|
||||||
|
'itemType', v_item.item_type,
|
||||||
|
'actionType', v_item.action_type,
|
||||||
|
'success', false,
|
||||||
|
'error', SQLERRM
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
v_all_approved := FALSE;
|
||||||
|
END;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- ========================================================================
|
||||||
|
-- STEP 4: Determine final submission status
|
||||||
|
-- ========================================================================
|
||||||
|
v_final_status := CASE
|
||||||
|
WHEN v_all_approved THEN 'approved'
|
||||||
|
WHEN v_some_approved THEN 'partially_approved'
|
||||||
|
ELSE 'rejected'
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- ========================================================================
|
||||||
|
-- STEP 5: Update submission status
|
||||||
|
-- ========================================================================
|
||||||
|
UPDATE content_submissions
|
||||||
|
SET
|
||||||
|
status = v_final_status,
|
||||||
|
reviewer_id = p_moderator_id,
|
||||||
|
reviewed_at = NOW(),
|
||||||
|
assigned_to = NULL,
|
||||||
|
locked_until = NULL
|
||||||
|
WHERE id = p_submission_id;
|
||||||
|
|
||||||
|
-- ========================================================================
|
||||||
|
-- STEP 6: Log metrics
|
||||||
|
-- ========================================================================
|
||||||
|
INSERT INTO approval_transaction_metrics (
|
||||||
|
submission_id,
|
||||||
|
moderator_id,
|
||||||
|
submitter_id,
|
||||||
|
items_count,
|
||||||
|
duration_ms,
|
||||||
|
success,
|
||||||
|
request_id
|
||||||
|
) VALUES (
|
||||||
|
p_submission_id,
|
||||||
|
p_moderator_id,
|
||||||
|
p_submitter_id,
|
||||||
|
array_length(p_item_ids, 1),
|
||||||
|
EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000,
|
||||||
|
v_all_approved,
|
||||||
|
p_request_id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ========================================================================
|
||||||
|
-- STEP 7: Build result
|
||||||
|
-- ========================================================================
|
||||||
|
v_result := jsonb_build_object(
|
||||||
|
'success', TRUE,
|
||||||
|
'results', to_jsonb(v_approval_results),
|
||||||
|
'submissionStatus', v_final_status,
|
||||||
|
'itemsProcessed', v_items_processed,
|
||||||
|
'allApproved', v_all_approved,
|
||||||
|
'someApproved', v_some_approved
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Clear session variables (defense-in-depth)
|
||||||
|
PERFORM set_config('app.current_user_id', '', true);
|
||||||
|
PERFORM set_config('app.submission_id', '', true);
|
||||||
|
PERFORM set_config('app.moderator_id', '', true);
|
||||||
|
|
||||||
|
RAISE NOTICE '[%] Transaction completed successfully in %ms',
|
||||||
|
COALESCE(p_request_id, 'NO_REQUEST_ID'),
|
||||||
|
EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000;
|
||||||
|
|
||||||
|
RETURN v_result;
|
||||||
|
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
-- ANY unhandled error triggers automatic ROLLBACK
|
||||||
|
RAISE WARNING '[%] Transaction failed, rolling back: % (SQLSTATE: %)',
|
||||||
|
COALESCE(p_request_id, 'NO_REQUEST_ID'),
|
||||||
|
SQLERRM,
|
||||||
|
SQLSTATE;
|
||||||
|
|
||||||
|
-- Log failed transaction metrics
|
||||||
|
INSERT INTO approval_transaction_metrics (
|
||||||
|
submission_id,
|
||||||
|
moderator_id,
|
||||||
|
submitter_id,
|
||||||
|
items_count,
|
||||||
|
duration_ms,
|
||||||
|
success,
|
||||||
|
rollback_triggered,
|
||||||
|
error_message,
|
||||||
|
request_id
|
||||||
|
) VALUES (
|
||||||
|
p_submission_id,
|
||||||
|
p_moderator_id,
|
||||||
|
p_submitter_id,
|
||||||
|
array_length(p_item_ids, 1),
|
||||||
|
EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000,
|
||||||
|
FALSE,
|
||||||
|
TRUE,
|
||||||
|
SQLERRM,
|
||||||
|
p_request_id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Clear session variables before re-raising
|
||||||
|
PERFORM set_config('app.current_user_id', '', true);
|
||||||
|
PERFORM set_config('app.submission_id', '', true);
|
||||||
|
PERFORM set_config('app.moderator_id', '', true);
|
||||||
|
|
||||||
|
-- Re-raise the exception to trigger ROLLBACK
|
||||||
|
RAISE;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Grant execute permissions
|
||||||
|
GRANT EXECUTE ON FUNCTION process_approval_transaction TO authenticated;
|
||||||
|
GRANT EXECUTE ON FUNCTION create_entity_from_submission TO authenticated;
|
||||||
|
GRANT EXECUTE ON FUNCTION update_entity_from_submission TO authenticated;
|
||||||
|
GRANT EXECUTE ON FUNCTION delete_entity_from_submission TO authenticated;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- Enable RLS on approval_transaction_metrics table
|
||||||
|
ALTER TABLE approval_transaction_metrics ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Policy: Only moderators and admins can view metrics
|
||||||
|
CREATE POLICY "Moderators can view approval metrics"
|
||||||
|
ON approval_transaction_metrics
|
||||||
|
FOR SELECT
|
||||||
|
TO authenticated
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM user_roles
|
||||||
|
WHERE user_roles.user_id = auth.uid()
|
||||||
|
AND user_roles.role IN ('moderator', 'admin', 'superuser')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Policy: System can insert metrics (SECURITY DEFINER functions)
|
||||||
|
CREATE POLICY "System can insert approval metrics"
|
||||||
|
ON approval_transaction_metrics
|
||||||
|
FOR INSERT
|
||||||
|
TO authenticated
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
||||||
|
COMMENT ON POLICY "Moderators can view approval metrics" ON approval_transaction_metrics IS
|
||||||
|
'Allows moderators, admins, and superusers to view approval transaction metrics for monitoring and analytics';
|
||||||
|
|
||||||
|
COMMENT ON POLICY "System can insert approval metrics" ON approval_transaction_metrics IS
|
||||||
|
'Allows the process_approval_transaction function to log metrics. The function is SECURITY DEFINER so it runs with elevated privileges';
|
||||||
Reference in New Issue
Block a user