Skip to content
Article Trait reference
☀️ Light Clean and bright
📜 Sepia Warm and vintage
🌤️ Light Gray Subtle and neutral
🔵 Light Blue Calm and serene
🌙 Dark Easy on the eyes
🌑 Dark Gray Deep and modern
🔷 Dark Blue Professional and sleek
🌿 Forest Calm and natural

Trait reference

An app class is mostly composition. The traits under SepoEngine\Traits supply identity, CRUD, sharing, formatting and security; the app supplies tables, methods and declarations.

Each trait below lists what it gives you, what it assumes exists on the class, and the behaviour that will surprise you.

At a glance

TraitGives youAssumes
AuthUserTraitWho is calling, and the auth DB handleEnv config
CrudSecurityTraitCapability and resource permission checks$this->pdo on the auth database
StandardCrudTraitCreate, update, delete, bulk, cascade, syncinferAliasFromId, $multi, $validator, $shell, $executor
SharingTraitOwnership-scoped reads, share managementinferAliasFromId, getSharingKeyMap
RecordInjectionTraitPer-row _can_* flags; payload cleaningAuthUserTrait
FormatterTraitOutput shaping, WHERE building, file URLs$multi (for setupTransformers only)
SyncableTraitReplication hooks$this->pdo, $this->multi
UniversalSyncLockTraitCascading state togglessyncUpdateRecord
HasUserFiltersUser-type query scopes
ResponseTraitsuccess() / error() envelopes
UidGenTraitUUID v4 generation

Order of use statements does not matter to PHP here — none of these traits declare conflicting members. Order them for readability.


AuthUserTrait

Answers "who is calling?" and owns a connection to the auth database.

protected function getAuthEnginePDO(): PDO
protected function getAuthenticatedUser(): string
protected function getUserRoleTag(string $uid): string
protected function getRequesterFlag(string $uid): string
protected function isRequesterAdmin(string $uid): bool

Identity is a request header. getAuthenticatedUser() constructs a Request and reads api-user-key. It returns '' on any failure rather than throwing — callers must treat empty as "no user", and the CRUD layer does exactly that (an empty UID is a denial on any ownership-scoped table, not a bypass).

Admin means one of two flags: UserType::ADMIN_FLAG or the literal ~SYS_USER. Admin status short-circuits sharing filters, row-level write checks, and the resource grant check.

Connection. getAuthEnginePDO() reads SQLITE_ENABLED and either opens a SQLite file under storage/ or a MySQL connection using AUTH_DB_HOST, AUTH_DB_NAME, AUTH_DB_USER and AUTH_DB_PASS_ENC — the password is stored encrypted and decrypted with CryptoAES and the AES key. The env key is AUTH_DB_PASS_ENC, not AUTH_DB_PASS.

Gotchas

  • self::$cachedPdo is a static in a trait, which PHP scopes per using class, not globally. Each app class gets its own handle. Combined with $this->pdo from the shell, one app can hold two connections to the same auth database.
  • getUserRoleTag() caches per request in $runtimeRoleCache. getRequesterFlag() and isRequesterAdmin() do not cache — every call is a query. deleteBulk calls deleteRecord per row, which calls isRequesterAdmin per row. Expect N+1 on large batches.

CrudSecurityTrait

The two declarative permission layers.

protected function verifyAccess(?string $uid, string $operation, array $customMap = []): array
protected function isResourcePermitted(?string $uid, array $subjects, string $resourceType,
                                       string $action, string $appSource, array $securityMap = []): bool
protected function isTablePermitted(string $uid, string $table, string $mappedPermission): bool
public    function setSecurityPdo(PDO $pdo): void

verifyAccess — capability

Maps a method name to a permission column (can_select_data, can_update_data, can_create_table, …), then checks that column for the user in tsp_sys_security. No row there falls back to defaults derived from the user's sp_flag via SystemObject::getFallbackPermissions.

Returns an array, not a bool. Check ['status'] === 'error' and return it verbatim — the message is already client-safe.

The custom map replaces the base map. $fullMap = !empty($customMap) ? $customMap : $baseMap;. Pass a $methodSecurityMap and the ~50 built-in mappings stop applying to your app entirely. Your map must be complete.

An unmapped operation returns SECURITY_MAPPING_MISSING. That is a deliberate fail-closed: forgetting to map a new method denies it rather than defaulting it to something permissive.

isResourcePermitted — resource grant

Checks tsp_sys_user_resources for a row matching any of $subjects (the user UID, then their role tag — order is the precedence order), scoped to sp_resource_source = APP_ID and sp_resource_type = <physical table>. Falls back to a '*' row for the app. No matching row means denied.

Gotchas

  • resolveSecurityPdo() finds $this->pdo first. It must be the auth connection. Failing that it reflects into $authModel, $apiKeyModel or $model properties looking for a PDO, and throws if none is found.
  • isResourcePermitted uses ORDER BY FIELD(...), which is MySQL-only. On SQLite the query throws, the catch (\Throwable) returns false, and every resource check silently denies. If your app must run on SQLite, this needs a portable ordering expression — a CASE ladder does the same job.
  • isTablePermitted interpolates $mappedPermission into the SQL string unescaped. verifyAccess and isResourcePermitted only interpolate values drawn from their own whitelists, so they are safe; isTablePermitted takes its column name from the caller. Never pass user input to it.
  • Both resource methods return false on any exception. A denial can mean "no grant" or "the query failed". Log inside the catch while developing.

StandardCrudTrait

Generic CRUD with hooks, validation and row-level authorisation.

abstract protected function inferAliasFromId(string $idField): string;

public function createRecord(array $data): array
public function createBulk(array $payload): array
public function updateRecord(array $where, array $newData): array
public function updateBulkRecords(array $params): array
public function deleteRecord(array $where): array
public function deleteBulk(...$args): array
public function cascadeDeleteRecord(array $payload): array
public function syncUpdateRecord(array $where, array $newData): array
public function syncDeleteRecord(array $where): array
protected function transferData(string $sourceAlias, array $conditions, string $sourcePk,
                                array $fieldMap, array $updateSource = []): array

What a create does

  1. Unwraps a single-element list payload.
  2. Walks the payload keys through inferAliasFromId to find the alias.
  3. If the PK is empty, generates a UID via $this->shell->generateUid() and stamps sp_owner_uid with the current user.
  4. Runs before_<alias>_create.
  5. Validates through DataIntegrityValidator.
  6. Inserts.
  7. Runs after_<alias>_create.

Step 3 is conditional: supply a non-empty PK and sp_owner_uid is not set. That is the correct behaviour for a caller-assigned UID, but it means an insert with an explicit UID and no owner column produces an unowned row that the sharing filters will hide from everyone but admins.

Row-level authorisation

updateRecord, deleteRecord and cascadeDeleteRecord fetch the target row before writing and call assertRecordAccessible(). syncUpdateRecord and syncDeleteRecord do the same as an authorize stage in their StageExecutor pipeline.

checkRecordAccess() decides:

SituationUpdateDelete
Admintruetrue
Table has no sp_owner_uidtruetrue
Caller owns the rowtruefalse
sp_shared_with entry with admintruetrue
Anything else, or no authenticated UIDfalsefalse

The "no sp_owner_uid" row is the one to watch: an unscoped table is open to anyone who cleared layers 1 and 2. Declare the column on every table holding user data.

Gotchas

  • cascadeDeleteRecord deletes children unscoped, by design — they belong to a parent that was already authorised. Make sure your cascade map only names genuine children.
  • The sync methods write to the mirror table directly, bypassing updateRecord/deleteRecord and therefore the target-side ownership check. Only the source row is authorised. Safe when source and target ownership always agree; verify that for your alias pairs.
  • transferData fetches the source unscoped. It is a migration utility. Do not expose it through exposeMethod.
  • deleteBulk's argument unwrapping is fragile. It inspects $args[0] heuristically to decide whether it received a list of criteria or a spread. Test the exact call shape your dispatcher produces before relying on it; a mis-detected payload surfaces as Unknown ID mapping: 0.
  • updateBulkRecords calls updateRecord per row and throws on the first failure — but it is not wrapped in a transaction, so earlier rows stay written.

SharingTrait

Ownership-scoped reads, plus management of the two sharing columns.

// scoped fetches
public function fetchWithSharing(string $alias, array $fields = ['*'], array $extraWhere = [], array $options = []): array
public function fetchSelectedWithSharing(array $aliases, array $fields = ['*'], array $extraWhere = [], array $options = []): array
public function fetchOwnerDataOnly(...)        // owner only, no shares
public function fetchSharedOnly(...)           // shares only, not owned
public function fetchPublicData(...)           // is_resource_public = 1
// … and fetchSelected* variants of each

// per-record sharing (sp_shared_with)
public function updateSharing(string $idField, string $uid, array $newShareList): array
public function revokeSharing(string $idField, string $uid, string $targetUserUid): array
public function getRecordSharing(string $idField, string $uid): array

// general distribution (sp_general_sharing)
public function updateGeneralSharing(string $idField, string $uid, array $newList): array
public function revokeGeneralSharing(string $idField, string $uid, string $targetValue): array
public function getGeneralRecordSharing(string $idField, string $uid): array

// approvals, group membership
public function updateApprovalStatus(...), getApprovalStatus(...)
public function addMembersToGroupGeneric(...), deleteMemberFromGroupGeneric(...), getGroupMembersGeneric(...)

The two sharing columns

sp_shared_with is an access-control list. Entries look like ['uid' => '…', 'permission' => 'view'|'edit'|'admin']. The fetch filters and checkRecordAccess both read it.

sp_general_sharing is an application-defined distribution list — it carries no access meaning. The School app uses it to hold a student's per-assignment scores on the grade row, and computes a weighted final grade from it at read time.

Because its entries are not keyed on uid, getSharingKeyMap() tells the trait which field identifies an entry:

protected function getSharingKeyMap(): array
{
    return ['sp_student_grade_uid' => 'sp_assignment_uid'];
}

Calling a general-sharing method for an ID field missing from that map throws.

How the scoping works

For a non-admin, fetchWithSharing builds:

['OR' => [
    "{$alias}.sp_owner_uid"        => $userUid,
    "{$alias}.sp_shared_with:json" => ['uid' => $userUid],
]]

then merges $extraWhere and re-applies the security block afterwards, so an extra filter cannot remove it.

For fetchSelectedWithSharing the constraint applies to $aliases[0] only. The first alias is the security anchor; every other alias in the array is joined without an ownership constraint of its own.

Gotchas

  • An OR key in $extraWhere widens access. The re-application step merges your OR conditions into the same OR group as the ownership filter, so a row matching your condition passes even if the user neither owns it nor is shared on it. Keep extra filters as plain AND keys, or scope them yourself.
  • Sharing merges, it never replaces. updateSharing merges each incoming entry into the existing list by uid. To remove access, call revokeSharing — sending a shorter list does nothing.
  • Every fetch* method appends a page key to each row containing current/limit/offset. It is metadata, not data — strip it before writing a row back.
  • fetchPublicData and the fetchSelectedPublicData variant require an is_resource_public column on the anchor table.

RecordInjectionTrait

Turns raw rows into rows the UI can reason about.

protected function injectSharedPermission(array $rows): array
protected function injectFullSecurityContext(array $rows): array   // alias for the above
protected function cleanData(array $data): array

injectSharedPermission adds to every row:

KeyMeaning
_is_ownersp_owner_uid matches the caller
_is_reviewer, _is_respondentRole columns, when present
_is_system_adminCaller holds an admin flag
_user_permission_groupDisplay label
_can_view / _can_update / _can_delete / _can_share / _can_commentCapability flags
_permissionRolled-up string: admin, edit, view, delete_only, none

The flags are the intersection of two things: the user's global CRUD rights (from tsp_sys_security) and their record-level standing (owner, or a sp_shared_with entry). A shared admin grant on a record does not confer delete if the user lacks can_delete_data globally.

_can_share is narrower still: it needs global update rights and either ownership or an admin share.

Gotchas

  • cleanData() is never called for you. The _* keys travel to the client and come back on any round-tripped row. Call cleanData() on incoming payloads yourself, or the write will carry columns that do not exist.
  • Injection issues one query per call to fetch the user's CRUD permissions — fine per request, wasteful if you call it in a loop. Inject once, over the whole result set.

FormatterTrait

Output shaping and query-building helpers. Nothing here touches the database except setupTransformers.

Output shapes

formatForCategory(array $rows, string $idKey, string $textKey, string $valueKey = '', string $dataKey = ''): array

Produces the select/dropdown shape the front end requires. Do not change it:

[{ "value": "…", "label": "…", "id": "…", "data": { "id": "…" }, "text": "…" }]
formatForGroupUI(array $rows, string $groupTitle = 'Group', ?string $groupField = null): array

Returns a map of group value → rows. With no $groupField you get a single bucket under $groupTitle; missing values land under Uncategorized.

Group by an identifier, not a display string. The reference app groups grades by sp_user_fullname, so two students with the same name share a bucket. Group by the UID and carry the name as a field.

buildTree(array $rows, array $hierarchy, array $idMap, array $titleMap): array

Builds a nested tree from flat joined rows. Nodes are keyed on the path to the node, not the bare UID, so the same course under two different programs stays distinct.

Also available: pivot, resolveHierarchy, formatByParentName, groupBy, sortBy, pluck, map, where, resultMerge.

buildFilteredWhere

buildFilteredWhere(string $alias, array $filters = [], array $extras = [], array $skipZeroColumns = []): array

The right way to build a WHERE from optional parameters. It drops '', null, 'all' and empty arrays; ignores numeric keys; qualifies bare column names with the alias; and recurses into OR blocks. Use it wherever a filter can legitimately be absent.

formatFileUrl

formatFileUrl(?string $path, string $default = ''): string

Normalises a stored file path into a URL against RouterSettings::get('plugin_url'). Handles absolute Windows paths left over from earlier uploads by splitting on sepodesk/, and passes complete URLs through untouched. Always run stored file paths through it rather than concatenating a base URL yourself.

setupTransformers

Registers an output transformer on $this->multi that flattens prefixed column keys — courses__sp_course_title becomes sp_course_title. Call it in the constructor. Its consequence: two joined tables with the same column name collide after flattening, last one wins. Alias one of them in your field list if you need both.


SyncableTrait

protected function attachSyncHooks(array $aliasToUidMap): void
protected function dispatchRelayPush(string $recipientEmail, string $contextType, array $messageBody): void

attachSyncHooks registers after_<alias>_create|update|delete listeners that forward the event to SyncHookListener with the row's UID field. Call it from setupHooks() with a map of alias → primary UID column.

It returns silently when the PDO driver is neither sqlite nor mysql, and silently for any alias not in the map. Both are quiet failures — an alias missing from the map simply never replicates.


UniversalSyncLockTrait

public function toggleHierarchyState(string $primaryUid, array $config, $value = 0): array

Flips one field across a root record, its children and any linked records — the "lock this whole tree" operation.

$config = [
    'root'     => ['pk' => 'sp_budget_uid', 'field' => 'sp_locked'],
    'children' => [
        ['alias' => 'bi', 'pk' => 'sp_item_uid', 'fk' => 'sp_budget_uid', 'field' => 'sp_locked'],
    ],
    'links'    => [
        ['pk' => 'sp_other_uid', 'target_uid' => $uid, 'field' => 'sp_locked'],
    ],
];

Child failures are caught per row and logged, so one bad record does not abort the sweep. It writes through syncUpdateRecord, so every row still passes its own ownership check — a partial result is the expected outcome when the caller owns some of the tree but not all of it.


HasUserFilters

public function scopeAdmin(array &$where): void
public function scopeStandardUser(array &$where): void
public function isType(array $row, int $typeNum): bool

Small helpers that set sp_flag on a WHERE array by reference, for queries against the users table. No database access.


ResponseTrait

protected function success($message = "Operation successful", $data = []): array
protected function error($message = "An error occurred"): array
protected function apiResponse(string $status, $data, ?string $key = 'message'): array

success() behaves differently depending on the argument type. Pass a string and you get ['status' => 'success', 'message' => …]. Pass an array and it is merged at the top levelsuccess(['data' => $row]) yields ['status' => 'success', 'data' => $row] with no message key at all.

StandardCrudTrait uses both forms: updateRecord returns the array form, deleteRecord returns the string form. Clients must handle a response where message may be absent.

Pick one form per method and keep it stable — the response shape is part of your API.


UidGenTrait

public function generateUid(): string

RFC 4122 version 4 UUID from random_bytes. Note that StandardCrudTrait generates UIDs through $this->shell->generateUid(), not through this trait, so an app class does not need to use it unless it mints UIDs itself.