Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
67.19% covered (warning)
67.19%
86 / 128
n/a
0 / 0
CRAP
n/a
0 / 0
1<?php
2
3/**
4 * @file
5 * Place action: create new place (insert PLACE in central DB, create per-place database).
6 */
7
8declare(strict_types=1);
9
10/**
11 * Action callable: create a new place (insert PLACE in central DB with status PA, create place database).
12 *
13 * Phases:
14 * 1. DML: INSERT PLACE under `$conx->global->transactional()` (Galera certification retry).
15 * 2. DDL: CREATE DATABASE on the batch server connection (no DML TX open) + presence check.
16 * 3. Deploy `p_schema.json` on the new tenant DB.
17 * 4. On mid-flight failure after the INSERT committed: DROP DATABASE + DELETE PLACE (compensations).
18 *
19 * Requires valid session (device + session). Only status, user and optionally _uuid are set; the client completes data via sync (UPDATE).
20 * Helpers stay inside this closure so the action file can be `require`d more than once in the same
21 * process (PlaceService cache + unit tests) without "Cannot redeclare" on a top-level function.
22 *
23 * @param string $body JSON body; only _uuid is used if present.
24 * @param array<string, mixed> $query Query params: device, session.
25 * @param \ConxHelper $conx Connection helper.
26 * @param \Psr\Log\LoggerInterface $logger Logger.
27 * @param \UppServices\SessionService $sessionService Session service for check.
28 * @return array{output: string, contentType: string}
29 */
30return function (string $body, array $query, \ConxHelper $conx, \Psr\Log\LoggerInterface $logger, \UppServices\SessionService $sessionService): array {
31    /**
32     * Drop a place database (compensation after failed schema deploy or mid-flight failure).
33     *
34     * @param string $database Database name (e.g. P00000001).
35     * @param \ConxHelper $conx Connection helper (uses ->server for DROP DATABASE).
36     */
37    $dropDatabase = static function (string $database, \ConxHelper $conx): void {
38        $conx_server = $conx->server;
39        if ($conx_server === null) {
40            return;
41        }
42        $dbname = '`'.str_replace('`', '``', $database).'`';
43        $results = [];
44        $conx_server->runsql([], 'DROP DATABASE IF EXISTS '.$dbname, $results);
45    };
46
47    /**
48     * Confirm the place database exists on the batch server connection (Galera TOI CREATE is
49     * cluster-wide when mysqli returns OK; this check asserts local visibility).
50     *
51     * @param string $database Database name (e.g. P00000001).
52     * @param \ConxHelper $conx Connection helper (uses ->server).
53     */
54    $databaseExists = static function (string $database, \ConxHelper $conx): bool {
55        $conx_server = $conx->server;
56        if ($conx_server === null) {
57            return false;
58        }
59        
60        $mysqli = $conx_server->mysqli ?? null;
61        if ($mysqli === null) {
62            return false;
63        }
64        
65        $escaped = mysqli_real_escape_string($mysqli, $database);
66        $results = [];
67        
68        $ok = $conx_server->runsql(
69            [],
70            "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = '".$escaped."'",
71            $results
72        );
73        
74        return $ok && is_array($results) && count($results) > 0;
75    };
76
77    /**
78     * Delete a PLACE row in central DB under transactional() (compensation after committed INSERT).
79     *
80     * @param int $objid PLACE.objid to delete.
81     * @param \ConxHelper $conx Connection helper (uses ->global).
82     */
83    $deletePlace = static function (int $objid, \ConxHelper $conx, \Psr\Log\LoggerInterface $logger): bool {
84        if ($conx->global === null) {
85            $logger->error("PlaceService::create: compensation DELETE PLACE failed (no global connection) objid=".$objid.".");
86            return false;
87        }
88        
89        $iteminfo = ['objid' => $objid];
90        
91        $ok = $conx->global->transactional(static function (\transaction $tx) use (&$iteminfo): bool {
92            return (bool) $tx->delete('PLACE', $iteminfo);
93        });
94        
95        if (!$ok) {
96            $logger->error("PlaceService::create: compensation DELETE PLACE failed for objid=".$objid.".");
97        }
98        
99        return $ok;
100    };
101
102    /**
103     * Best-effort rollback of a mid-flight create after INSERT PLACE has already committed.
104     *
105     * @param string|null $placeDb Database name if CREATE may have run; null to skip DROP.
106     * @param int|null $objid PLACE.objid if INSERT committed; null to skip DELETE.
107     */
108    $compensate = static function (
109        ?string $placeDb,
110        ?int $objid,
111        \ConxHelper $conx,
112        \Psr\Log\LoggerInterface $logger
113    ) use ($dropDatabase, $deletePlace): void {
114        if ($placeDb !== null) {
115            $dropDatabase($placeDb, $conx);
116        }
117        
118        if ($objid !== null) {
119            $deletePlace($objid, $conx, $logger);
120        }
121    };
122
123    $retval = \ApplicationError::Success;
124    $placeData = null;
125    $objid = null;
126    $placeDb = null;
127    $placeInserted = false;
128    $pSchema = null;
129    $device = isset($query['device']) ? (string) $query['device'] : '';
130    $session = isset($query['session']) ? (string) $query['session'] : '';
131    $bodyData = $body !== '' ? (json_decode($body, true) ?? []) : [];
132
133    if (\success($retval)) {
134        if ($device === '' || $session === '') {
135            $logger->error("PlaceService::create: missing device or session in query.");
136            $retval = \ApplicationError::Parameters;
137        }
138    }
139
140    if (\success($retval)) {
141        if ($conx->global === null) {
142            $logger->error("PlaceService::create: failed to connect to central database.");
143            $retval = \ApplicationError::Database;
144        }
145    }
146
147    if (\success($retval)) {
148        $userid = null;
149        
150        $retval = $sessionService->checkSession($conx, $session, $device, $userid);
151        if (\success($retval)) {
152            $placeTenant = \AppConstants::tenantNode();
153            $placeData = [
154                'status' => 'PA',
155                'user' => (int) $userid,
156                'tenant' => $placeTenant,
157            ];
158            
159            if (isset($bodyData['_uuid']) && $bodyData['_uuid'] !== null && $bodyData['_uuid'] !== '') {
160                $placeData['_uuid'] = (string) $bodyData['_uuid'];
161            }
162        }
163    }
164
165    // Phase 1 — DML only (retryable on Galera certification conflict).
166    if (\success($retval)) {
167        $insertedObjid = null;
168        
169        $ok = $conx->global->transactional(static function (\transaction $tx) use ($placeData, &$insertedObjid): bool {
170            $objidRef = null;
171            
172            if (!$tx->insert('PLACE', $placeData, $objidRef)) {
173                return false;
174            }
175            
176            $insertedObjid = (int) $objidRef;
177            return true;
178        });
179        
180        if (!$ok || $insertedObjid === null) {
181            $logger->error("PlaceService::create: failed to insert PLACE row.");
182            $retval = \ApplicationError::Database;
183        } 
184        else {
185            $objid = $insertedObjid;
186            $placeInserted = true;
187            $placeDb = 'P'.\str_pad((string) $objid, 8, '0', STR_PAD_LEFT);
188        }
189    }
190
191    // Phase 2 — DDL on batch server (no DML TX open).
192    if (\success($retval)) {
193        $batch_key = (string) $placeData['tenant'];
194        $batch_srv = $conx->getBatch($batch_key);
195        
196        if ($batch_srv === null) {
197            $logger->error("PlaceService::create: failed to connect for CREATE DATABASE (batch ".$batch_key.").");
198            $retval = \ApplicationError::Database;
199        }
200    }
201
202    if (\success($retval)) {
203        if (!\create_ddbb_schema($conx->server, $placeDb)) {
204            $logger->error("PlaceService::create: failed to create database '".$placeDb."'.");
205            $retval = \ApplicationError::Database;
206        }
207    }
208
209    if (\success($retval)) {
210        if (!$databaseExists($placeDb, $conx)) {
211            $logger->error("PlaceService::create: database '".$placeDb."' not visible after CREATE DATABASE.");
212            $retval = \ApplicationError::Database;
213        }
214    }
215
216    // Phase 3 — deploy p_schema on the new tenant DB.
217    if (\success($retval)) {
218        $conx->tenant = $objid;
219        if ($conx->tenant === null) {
220            $logger->error("PlaceService::create: failed to connect to place database '".$placeDb."'.");
221            $retval = \ApplicationError::Database;
222        }
223    }
224
225    if (\success($retval)) {
226        $pSchemaPath = __DIR__.'/../../Resources/model/p_schema.json';
227        if (!\load_ddbb_schema_from($pSchemaPath, $pSchema)) {
228            $logger->error("PlaceService::create: failed to load place schema from JSON.");
229            $retval = \ApplicationError::Database;
230        }
231    }
232
233    if (\success($retval)) {
234        if (!\update_ddbb_schema($conx->tenant, $pSchema)) {
235            $logger->error("PlaceService::create: failed to deploy schema to place database '".$placeDb."'.");
236            $retval = \ApplicationError::Database;
237        }
238    }
239
240    // Phase 4 — compensations when INSERT already committed and a later phase failed.
241    if (!\success($retval) && $placeInserted) {
242        $compensate($placeDb, $objid, $conx, $logger);
243    }
244
245    $result = [
246        'errorcode' => $retval,
247        'objid' => \success($retval) ? $objid : null,
248    ];
249    
250    return ['output' => json_encode($result), 'contentType' => 'application/json; charset=utf-8'];
251};