Architecture complète de la gestion des immeubles et lots Talok côté propriétaire : modèle données (buildings, building_units, properties), ownership_type full/partial, wizard de création, page hub managérial, routes UI et API, distinction avec le module syndic, règles de scoping baux/factures/tickets. Déclenche dès que la tâche touche à immeuble, lot, building, copropriétaire partiel, plan des lots, fiche immeuble, wizard building_config, BuildingVisualizer, ou route /owner/buildings.
Talok distingue trois concepts strictement séparés :
| Concept | Définition | Exemples |
|---|---|---|
| Bien unitaire | Une property standalone sans parent | Un appart isolé, une maison, un studio |
| Lot d'immeuble | Une property avec parent_property_id pointant vers un immeuble wrapper | Un appart dans une SCI qui possède plusieurs apparts au même endroit |
| Immeuble conteneur | Une property type='immeuble' + un record buildings + N building_units | La SCI Route du Phare, 4 apparts |
Règle d'or : le propriétaire ne voit jamais le wrapper type='immeuble' comme un bien dans la liste "Mes biens". Il voit ses lots individuellement (avec un badge vers l'immeuble parent) et l'immeuble comme un hub managérial dans l'onglet "Immeubles".
properties — Tous les biens (lots + unitaires + wrappers)
├── id (UUID PK)
├── owner_id (FK profiles)
├── type (enum 14 types — inclut 'immeuble' pour le wrapper)
├── parent_property_id (UUID, FK properties) — non-null pour les lots, pointe vers le wrapper
├── legal_entity_id (FK legal_entities) — entité SCI/EIRL/etc.
├── adresse_complete, code_postal, ville, ...
├── loyer_hc, charges, depot_garantie, ...
└── etat, deleted_at
buildings — Métadonnées physiques + équipements communs
├── id (UUID PK)
├── owner_id (FK profiles) NOT NULL
├── property_id (FK properties) — pointe vers le wrapper type='immeuble', nullable
├── ownership_type (ENUM: 'full' | 'partial') DEFAULT 'full' ← à ajouter
├── total_lots_in_building (INTEGER nullable) ← à ajouter (informatif si partial)
├── name, adresse_complete, code_postal, ville, departement
├── floors, construction_year, surface_totale
├── has_ascenseur, has_gardien, has_interphone, has_digicode
├── has_local_velo, has_local_poubelles, has_parking_commun, has_jardin_commun
├── notes, deleted_at
└── created_at, updated_at
building_units — Plan d'étage (floor/position) + ref lot
├── id (UUID PK)
├── building_id (FK buildings) ON DELETE CASCADE NOT NULL
├── property_id (FK properties) NULLABLE — la property lot correspondante
├── floor, position (UNIQUE per building)
├── type (appartement|studio|local_commercial|parking|cave|bureau)
├── template (studio|t1|t2|t3|t4|t5|local|parking|cave)
├── surface, nb_pieces, loyer_hc, charges, depot_garantie
├── status (vacant|occupe|travaux|reserve)
├── current_lease_id (FK leases) ON DELETE SET NULL
└── created_at, updated_at
properties (type='immeuble', wrapper)
↑ property_id
↑
buildings
↓ id
↓
building_units
↓ property_id
↓
properties (type≠'immeuble', le lot réel)
↑ parent_property_id → wrapper
Le wrapper property existe pour faire la jointure entre buildings et les lots. Il n'est jamais exposé comme un bien dans les listes utilisateur.
ownership_type (à ajouter)| Valeur | Signification |
|---|---|
'full' | Le propriétaire possède TOUS les lots de l'immeuble physique. Talok gère tout. |
'partial' | Le propriétaire possède seulement CERTAINS lots (copropriété avec d'autres propriétaires). Talok ne gère que ses lots à lui, les parties communes sont en lecture-seule, la gouvernance est externe (syndic). |
Backfill : tous les records existants → 'full' (comportement actuel).
Un immeuble = N biens dans le quota où N est le nombre de lots du user.
buildings → ne consomme pas de slot
properties type=immeuble → ne consomme pas de slot (wrapper technique)
properties (lots) → consomme 1 slot chacun
properties (unitaires) → consomme 1 slot chacun
Fichier : lib/subscriptions/check-limit.ts — filtre neq('type', 'immeuble').
Exemple : SCI Marie-Line possède 2 lots dans Route du Phare (ownership_type='partial', total=6) + 1 maison standalone = 3 biens dans le quota.
type='immeuble'1. type_bien — sélection "Immeuble (entier ou partiel)"
2. address — adresse du bâtiment
3. ownership_type — NEW : radio full/partial + total_lots_in_building
4. building_config — BuildingVisualizer + lots de l'utilisateur
5. photos — photos extérieures + parties communes
6. recap — récapitulatif + submit
POST /api/properties/[id]/building-units fait en transaction :
buildings (avec ownership_type, total_lots_in_building)properties (type != 'immeuble', parent_property_id = wrapper.id, etat='published')building_units (avec property_id pointant vers chaque lot)Source : app/api/properties/[id]/building-units/route.ts:49-269.
partial| Route | Fichier | Description |
|---|---|---|
/owner/properties | app/owner/properties/page.tsx | Liste unifiée avec tabs Mes biens / Immeubles |
/owner/properties?tab=immeubles | idem | Vue managériale des immeubles conteneurs |
/owner/properties/new | app/owner/properties/new/NewPropertyClient.tsx | Wizard (inclut branche immeuble) |
/owner/properties/[id] | app/owner/properties/[id]/page.tsx | Fiche d'un lot OU d'un bien unitaire |
/owner/buildings | app/owner/buildings/page.tsx | Redirect 301 → /owner/properties?tab=immeubles |
/owner/buildings/[id] | app/owner/buildings/[id]/page.tsx | Hub managérial (plan + cards lots + docs) |
/owner/buildings/[id]/units | app/owner/buildings/[id]/units/page.tsx | Édition des lots (CRUD inline) |
/owner/buildings/[id]/not-found.tsx | app/owner/buildings/[id]/not-found.tsx | 404 dédié (à distinguer de 403) |
URL pattern : /owner/buildings/[id] utilise id = property_id du wrapper (pas building_id). Le server component résout le building_id via buildings.property_id.
| Endpoint | Méthode | Description |
|---|---|---|
/api/buildings | GET, POST | Liste + création |
/api/buildings/[id] | GET, PATCH, DELETE | Détail + update + soft-delete (fallback hard) |
/api/buildings/[id]/units | GET, POST | Liste + création bulk de lots |
/api/buildings/[id]/units/[unitId] | DELETE | Supprimer un lot |
/api/properties/[id]/building | GET | Récupère building + units pour une property type=immeuble |
/api/properties/[id]/building-units | POST | Upsert building + lots en une transaction (wizard) |
/api/properties/[id]/associate-building | POST | À créer — rattacher une property existante à un immeuble parent |
/owner/buildings/[id] — structureLa page est un hub managérial à 3 sections stackées verticalement (pas de tabs) :
┌─────────────────────────────────────────────────┐
│ HEADER │
│ Nom immeuble · adresse · année · surface │
│ Badge ownership : "Propriétaire unique" ou │
│ "Copropriétaire · X/M lots" │
│ CTA : Modifier | Gérer les lots │
├─────────────────────────────────────────────────┤
│ SECTION 1 — Plan des lots │
│ <BuildingVisualizer readOnly /> │
│ Lots user = cliquables (→ détail du lot) │
│ Lots externes (partial) = grisés │
├─────────────────────────────────────────────────┤
│ SECTION 2 — Lots │
│ Grille <BuildingLotCard /> réutilisable │
│ Par card : photo, étage/position, type, │
│ surface, loyer, statut, nom locataire, bail │
│ Clic → /owner/properties/[lot.property_id] │
├─────────────────────────────────────────────────┤
│ SECTION 3 — Documents de gestion │
│ DPE collectif, règlement copro, PV AG, │
│ attestation PNO, contrats entretien, devis │
│ Upload / aperçu / suppression │
└─────────────────────────────────────────────────┘
Composant BuildingLotCard (à créer dans components/buildings/BuildingLotCard.tsx) :
{ lot: PropertyRow, unit: BuildingUnitRow, lease?: LeaseRow, tenant?: ProfileRow }Link vers /owner/properties/${lot.id} (pas /owner/buildings/${building_id}/units/${unit_id} — on veut la vraie fiche du bien)/owner/properties/[id] pour un lotMême composant que pour un bien unitaire, mais avec différences :
Mes biens → Immeuble [X] → Lot [Y]/owner/buildings/[parent_property_id]Le lot est traité comme un bien autonome pour tout ce qui est bail/locataire/factures — seul le "wrapping" managérial est groupé.
Bug connu : app/owner/buildings/[id]/page.tsx:89-116 filtre owner_id = profile.id sans tenir compte des membres SCI (entity_members) ni du cas building.property_id IS NULL. Résultat : 404 pour des immeubles qui existent et sont légitimement accessibles.
Fix : copier le pattern de app/api/invoices/[id]/route.ts:78-106 :
1. Query property/building SANS filtre owner (service client)
2. Autoriser si :
- property.owner_id === profile.id
- OU profile.id ∈ (SELECT user_id FROM entity_members WHERE entity_id = property.legal_entity_id)
3. Sinon → 403 access-denied (page dédiée distincte du 404 not-found)
4. Si building.property_id IS NULL → rendre quand même avec buildingRecord direct
Filtre actuel (app/owner/properties/page.tsx:181) :
filtered = filtered.filter(p => p.type !== "immeuble" && !p.parent_property_id);
À remplacer par :
filtered = filtered.filter(p => p.type !== "immeuble");
// Les lots avec parent_property_id apparaissent maintenant comme des biens
Le badge "Immeuble · [adresse]" est ajouté dans PropertyCard :
property.parent_property_id != nullSELECT id, adresse_complete FROM properties WHERE id IN (distinct parent_property_ids) au niveau de la page (pas N+1)/owner/buildings/${parent_property_id}| Entité | Scope | FK canonique |
|---|---|---|
| Bail | Toujours au lot | leases.property_id = lot.id (+ leases.building_unit_id pour le trigger sync) |
| Facture | Via bail | invoices.lease_id (remonte au lot via leases.property_id) |
| Ticket privatif | Lot | tickets.property_id = lot.id |
| Ticket commun (parties communes) | Immeuble | tickets.building_id (colonne à ajouter, phase ultérieure) |
| Document privatif (EDL, quittance, etc.) | Lot | documents.property_id = lot.id |
| Document commun (DPE collectif, PV AG, PNO) | Immeuble | documents.building_id OU documents.property_id = wrapper.id |
Règle : un bail ne pointe jamais directement vers le wrapper type='immeuble'. Il pointe toujours vers un lot réel ou un bien unitaire.
building_units.building_id → buildings.id ON DELETE CASCADEbuildings.property_id → properties.id ON DELETE SET NULLbuilding_units.current_lease_id → leases.id ON DELETE SET NULLapp/api/buildings/[id]/route.ts:163-235 :
deleted_at = NOW()) préféré, fallback hard-deleteapp/api/buildings/[id]/units/[unitId]/route.ts :
.delete() physique sur building_unitsparent_property_idTODO : ajouter un trigger de cleanup ou un soft-delete cascade sur les properties lots.
/syndic/owner/buildings | /syndic |
|---|---|
| Vue propriétaire bailleur | Vue syndic / conseil syndical |
| Scopée aux lots possédés | Gouvernance de toute la copro |
| Obligations individuelles du user | Obligations collectives de la copro |
| Documents reçus du syndic | Documents émis par le syndic |
| Pas d'AG, pas de votes, pas d'appels de fonds émis | AG, convocations, votes, appels de fonds, mandats |
Un utilisateur peut avoir les deux rôles (syndic bénévole d'une copro dont il est aussi copropriétaire), mais les modules restent strictement séparés pour ne pas mélanger responsabilités légales.
owner_id seul sur les pages/routes building — toujours copier le pattern entity_members.getServiceClient(), jamais createClient() user-scoped (évite récursion RLS 42P17)./owner/buildings/[id] utilise property_id du wrapper, pas building_id. Le server component résout via buildings.property_id = property_id./owner/properties/[lot.property_id], jamais vers une fiche "lot" dédiée (qui n'existe pas).immeuble déclenche BUILDING_STEPS à la place de DEFAULT_STEPS. L'étape building_config utilise BuildingVisualizer + BuildingConfigStep..neq('type', 'immeuble') dans check-limit.ts.buildings, building_units, properties utilisent public.user_profile_id() — jamais auth.uid().npx supabase gen types typescript et commit lib/supabase/database.types.ts.| Domaine | Fichier |
|---|---|
| Types DB | lib/supabase/database.types.ts |
| Types building | lib/types/building-v3.ts |
| Wizard store | features/properties/stores/wizard-store.ts |
| Wizard UI | features/properties/components/v3/property-wizard-v3.tsx |
| Étape building config | features/properties/components/v3/immersive/steps/BuildingConfigStep.tsx |
| Visualiseur | features/properties/components/v3/immersive/steps/BuildingVisualizer.tsx |
| Page détail immeuble | app/owner/buildings/[id]/page.tsx + BuildingDetailClient.tsx |
| Page édition lots | app/owner/buildings/[id]/units/UnitsManagementClient.tsx |
| Not-found immeuble | app/owner/buildings/[id]/not-found.tsx |
| Liste immeubles | app/owner/properties/page.tsx (onglet immeubles) |
| API buildings | app/api/buildings/route.ts, app/api/buildings/[id]/** |
| API building-units (wizard) | app/api/properties/[id]/building-units/route.ts |
| API building lookup | app/api/properties/[id]/building/route.ts |
| Service | features/properties/services/buildings.service.ts |
| Quota | lib/subscriptions/check-limit.ts (exclusion wrapper) |
| RLS | supabase/migrations/20260318020000_buildings_rls_sota2026.sql |
| Table création | supabase/migrations/20260107000000_building_support.sql |
| Backfill lots | supabase/migrations/20260409170000_backfill_building_unit_properties.sql |
| Lease building_unit_id | supabase/migrations/20260409160000_building_unit_lease_document_fk.sql |
Pour les règles de prévention de régressions spécifiques au property system (RLS, types, rate limiting, Zod enums), voir aussi .cursor/skills/property-building-guard/SKILL.md et .cursor/skills/sota-property-system/SKILL.md.