Skip to content

Commit 8ed9c40

Browse files
authored
Merge pull request #11 from ConductionNL/wip/build-template-resync-2026-07-23
feat(admin-templates): template re-sync to existing copies + archive
2 parents fe84779 + 964da61 commit 8ed9c40

26 files changed

Lines changed: 3132 additions & 33 deletions

appinfo/routes.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,10 @@
372372
// `{uuid}/preview-image` suffix matches first.
373373
['name' => 'admin#uploadTemplatePreviewImage', 'url' => '/api/admin/templates/{uuid}/preview-image', 'verb' => 'POST',
374374
'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
375+
// Admin template re-sync (REQ-RESYNC-001). Registered BEFORE the
376+
// `/api/admin/templates/{id}` wildcard routes so the literal
377+
// `{id}/resync` suffix matches first, same as preview-image above.
378+
['name' => 'admin#resyncTemplate', 'url' => '/api/admin/templates/{id}/resync', 'verb' => 'POST'],
375379
['name' => 'admin#getTemplate', 'url' => '/api/admin/templates/{id}', 'verb' => 'GET'],
376380
['name' => 'admin#updateTemplate', 'url' => '/api/admin/templates/{id}', 'verb' => 'PUT'],
377381
['name' => 'admin#deleteTemplate', 'url' => '/api/admin/templates/{id}', 'verb' => 'DELETE'],
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Re-syncing an admin template
2+
3+
When an admin template is distributed, each targeted user receives an
4+
**independent personal copy**. That independence is what lets people
5+
personalise their dashboard — but it also means that, without this feature,
6+
correcting a template only ever reached *future* first-logins. A functioneel
7+
beheerder who fixed a wrong link in the Burgerzaken template still had 40
8+
colleagues looking at the old one.
9+
10+
Re-sync closes that gap: it pushes an updated template out to copies that
11+
already exist.
12+
13+
## The two strategies
14+
15+
| Strategy | What happens to the template's widgets | What happens to the user's own widgets |
16+
|----------|----------------------------------------|----------------------------------------|
17+
| **Merge** (default) | Updated to match the template | **Kept** |
18+
| **Overwrite** | Replaced wholesale with the template layout | **Removed** |
19+
20+
Use **merge** for routine corrections — a changed link, a new compulsory
21+
announcement — so nobody loses the shortcuts they added. Use **overwrite**
22+
only when you genuinely intend to reset a department to the standard layout,
23+
and tell people first.
24+
25+
Compulsory widgets are reconciled under **both** strategies: a widget the
26+
template pins cannot be missing from a copy after a re-sync.
27+
28+
## Always dry-run first
29+
30+
The action supports `dryRun`, which reports exactly which copies would change
31+
and what would happen to each — **without mutating anything**. Run it, read
32+
it, then run for real. This is the difference between "I think this is safe"
33+
and "I know what this will do to 40 people's screens."
34+
35+
```http
36+
POST /apps/launchpad/api/admin/templates/{id}/resync
37+
{ "strategy": "merge", "dryRun": true }
38+
```
39+
40+
## What else happens
41+
42+
- The operation is **idempotent** — running it twice produces no further change.
43+
- Each run writes an **audit record** (who, what, when).
44+
- Affected users are **notified**.
45+
- For large target groups the work is handed to a background job rather than
46+
blocking the request.
47+
48+
## Permissions
49+
50+
Admin-only, guarded both by the `AuthorizedAdminSetting` attribute and an
51+
explicit in-body admin assertion.
52+
53+
## Known limitation
54+
55+
Notifications are delivered via Nextcloud's `INotification` — the app's
56+
existing (and only) notification pattern. The `x-openregister-notifications`
57+
dialect branch is not wired in; see the archived change's `tasks.md`.
58+
59+
## Related
60+
61+
- [Admin Templates](admin-templates.md) — authoring and distributing templates.
62+
- [Permission Levels](permissions.md) — what a copy's permission level allows.
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
<?php
2+
3+
/**
4+
* TemplateResyncJob
5+
*
6+
* Applies an admin template re-sync asynchronously for large target
7+
* groups (REQ-RESYNC-005 "Large groups apply asynchronously"). Enqueued
8+
* by {@see \OCA\LaunchPad\Service\TemplateResyncService::resync()} — a
9+
* one-off {@see QueuedJob}, not registered at app boot; NC's background
10+
* job runner removes it from the queue once {@see self::run()} returns.
11+
*
12+
* @category BackgroundJob
13+
* @package OCA\LaunchPad\BackgroundJob
14+
* @author Conduction b.v. <info@conduction.nl>
15+
* @copyright 2026 Conduction b.v.
16+
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
17+
* @version GIT:auto
18+
* @link https://conduction.nl
19+
*
20+
* SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
21+
* SPDX-License-Identifier: EUPL-1.2
22+
*/
23+
24+
declare(strict_types=1);
25+
26+
namespace OCA\LaunchPad\BackgroundJob;
27+
28+
use OCA\LaunchPad\Service\TemplateResyncService;
29+
use OCP\AppFramework\Utility\ITimeFactory;
30+
use OCP\BackgroundJob\QueuedJob;
31+
use Psr\Log\LoggerInterface;
32+
use Throwable;
33+
34+
/**
35+
* One-off async apply for a large-target-group template re-sync.
36+
*
37+
* @SuppressWarnings(PHPMD.UnusedFormalParameter) — $argument's shape is
38+
* validated inline; the parent QueuedJob interface requires the
39+
* parameter regardless.
40+
* @spec openspec/specs/admin-templates/spec.md#requirement-req-resync-005-re-sync-is-idempotent-audited-async-capable-and-notifies-users
41+
*/
42+
class TemplateResyncJob extends QueuedJob
43+
{
44+
/**
45+
* Constructor.
46+
*
47+
* @param ITimeFactory $time Time factory (parent
48+
* requirement).
49+
* @param TemplateResyncService $resyncService The re-sync orchestrator —
50+
* {@see TemplateResyncService::applyResync()}
51+
* recomputes the plan
52+
* fresh at run time
53+
* (rather than
54+
* deserialising a
55+
* stale one), so the
56+
* apply reflects the
57+
* template's state at
58+
* the moment the job
59+
* actually runs.
60+
* @param LoggerInterface $logger PSR-3 logger.
61+
*/
62+
public function __construct(
63+
ITimeFactory $time,
64+
private readonly TemplateResyncService $resyncService,
65+
private readonly LoggerInterface $logger,
66+
) {
67+
parent::__construct(time: $time);
68+
}//end __construct()
69+
70+
/**
71+
* Apply the re-sync plan for `$argument['templateId']` /
72+
* `$argument['strategy']`, writing the audit record and notifying
73+
* every affected user on completion.
74+
*
75+
* Malformed arguments are logged and skipped rather than throwing —
76+
* a throw here would make NC's job runner retry indefinitely with the
77+
* same bad payload.
78+
*
79+
* @param mixed $argument `{templateId: int, strategy: string,
80+
* actingAdminId: string}`.
81+
*
82+
* @return void
83+
*
84+
* @spec openspec/specs/admin-templates/spec.md
85+
*/
86+
protected function run($argument): void
87+
{
88+
$templateId = (int) ($argument['templateId'] ?? 0);
89+
$strategy = (string) ($argument['strategy'] ?? '');
90+
$actingAdminId = (string) ($argument['actingAdminId'] ?? '');
91+
92+
if ($templateId <= 0 || $strategy === '' || $actingAdminId === '') {
93+
$this->logger->warning(
94+
message: 'launchpad.template_resync.job_skipped reason=invalid_arguments',
95+
context: ['argument' => $argument]
96+
);
97+
return;
98+
}
99+
100+
try {
101+
$result = $this->resyncService->applyResync(
102+
templateId: $templateId,
103+
strategy: $strategy,
104+
actingAdminId: $actingAdminId
105+
);
106+
107+
$this->logger->info(
108+
message: sprintf(
109+
'launchpad.template_resync.job_completed template=%d strategy=%s affected=%d total=%d',
110+
$templateId,
111+
$strategy,
112+
$result['affectedCount'],
113+
$result['totalCopies']
114+
)
115+
);
116+
} catch (Throwable $t) {
117+
$this->logger->error(
118+
message: 'launchpad.template_resync.job_failed',
119+
context: [
120+
'templateId' => $templateId,
121+
'strategy' => $strategy,
122+
'exception' => $t,
123+
]
124+
);
125+
}//end try
126+
}//end run()
127+
}//end class

lib/Controller/AdminController.php

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
use OCA\LaunchPad\Service\ResourceService;
3838
use OCA\LaunchPad\Service\RoleService;
3939
use OCA\LaunchPad\Service\SetupWizardService;
40+
use OCA\LaunchPad\Service\TemplateResyncService;
4041
use OCA\LaunchPad\Settings\LaunchPadAdmin;
4142
use OCP\AppFramework\Controller;
4243
use OCP\AppFramework\Db\DoesNotExistException;
@@ -123,6 +124,9 @@ class AdminController extends Controller
123124
* orchestrator
124125
* (REQ-WIZ-001..011).
125126
* @param ActionAuthService $actionAuth ADR-023 action authorization.
127+
* @param TemplateResyncService $resyncService Admin template
128+
* re-sync orchestrator
129+
* (REQ-RESYNC-001..005).
126130
*/
127131
public function __construct(
128132
IRequest $request,
@@ -137,6 +141,7 @@ public function __construct(
137141
private readonly FooterService $footerService,
138142
private readonly SetupWizardService $setupWizardService,
139143
private readonly ActionAuthService $actionAuth,
144+
private readonly TemplateResyncService $resyncService,
140145
) {
141146
parent::__construct(
142147
appName: Application::APP_ID,
@@ -331,6 +336,68 @@ public function deleteTemplate(int $id): JSONResponse
331336
}//end try
332337
}//end deleteTemplate()
333338

339+
/**
340+
* Push an updated admin template to its already-provisioned user
341+
* copies (REQ-RESYNC-001).
342+
*
343+
* Body: `{strategy: "overwrite"|"merge", dryRun: bool}`. Dry-run
344+
* (the default) computes and returns the plan — affected copies plus
345+
* per-copy add/update/remove/preserve counts — without mutating
346+
* anything. A real run (`dryRun: false`) applies inline for small
347+
* target groups or enqueues {@see \OCA\LaunchPad\BackgroundJob\TemplateResyncJob}
348+
* for large ones, writes one audit record, and notifies every
349+
* affected user.
350+
*
351+
* Admin-guarded twice over — the `AuthorizedAdminSetting` attribute
352+
* plus the explicit {@see self::assertAdmin()} guard — matching this
353+
* controller's other mutating admin actions (export/import/footer).
354+
*
355+
* @param int $id The admin template's dashboard ID.
356+
* @param string $strategy `'overwrite'` or `'merge'`.
357+
* @param bool $dryRun When true (default), report without
358+
* mutating.
359+
*
360+
* @return JSONResponse The plan, the applied result, or the
361+
* async-accepted envelope. 400 on an invalid
362+
* strategy or a non-template dashboard; 401/403
363+
* on guard failure.
364+
*
365+
* @spec openspec/specs/admin-templates/spec.md#requirement-req-resync-001-re-sync-action-pushes-template-updates-to-existing-copies
366+
*/
367+
#[AuthorizedAdminSetting(LaunchPadAdmin::class)]
368+
public function resyncTemplate(
369+
int $id,
370+
string $strategy='',
371+
bool $dryRun=true
372+
): JSONResponse {
373+
$guard = $this->assertAdmin();
374+
if ($guard !== null) {
375+
return $guard;
376+
}
377+
378+
$user = $this->userSession->getUser();
379+
$actingAdminId = ($user !== null) ? $user->getUID() : '';
380+
381+
try {
382+
$result = $this->resyncService->resync(
383+
templateId: $id,
384+
strategy: $strategy,
385+
dryRun: $dryRun,
386+
actingAdminId: $actingAdminId
387+
);
388+
389+
return ResponseHelper::success(data: $result);
390+
} catch (InvalidArgumentException $e) {
391+
return ResponseHelper::error(
392+
exception: $e,
393+
statusCode: Http::STATUS_BAD_REQUEST,
394+
message: $e->getMessage()
395+
);
396+
} catch (\Exception $e) {
397+
return ResponseHelper::error(exception: $e);
398+
}//end try
399+
}//end resyncTemplate()
400+
334401
/**
335402
* Get admin settings.
336403
*

lib/Db/DashboardMapper.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,36 @@ public function findAdminTemplates(): array
207207
return $this->findEntities(query: $qb);
208208
}//end findAdminTemplates()
209209

210+
/**
211+
* Find every user dashboard provisioned from a given admin template
212+
* (`basedOnTemplate = $templateId`). Used by
213+
* {@see \OCA\LaunchPad\Service\TemplateResyncService} to enumerate the
214+
* copies an admin re-sync targets (REQ-RESYNC-001).
215+
*
216+
* @param int $templateId The source template's dashboard ID.
217+
*
218+
* @return Dashboard[] The provisioned copies, ordered by `id` ASC.
219+
* @spec openspec/specs/admin-templates/spec.md
220+
*/
221+
public function findByBasedOnTemplate(int $templateId): array
222+
{
223+
$qb = $this->db->getQueryBuilder();
224+
$qb->select(selects: '*')
225+
->from(from: $this->getTableName())
226+
->where(
227+
$qb->expr()->eq(
228+
x: 'based_on_template',
229+
y: $qb->createNamedParameter(
230+
value: $templateId,
231+
type: IQueryBuilder::PARAM_INT
232+
)
233+
)
234+
)
235+
->orderBy(sort: 'id', order: 'ASC');
236+
237+
return $this->findEntities(query: $qb);
238+
}//end findByBasedOnTemplate()
239+
210240
/**
211241
* Find default admin template.
212242
*

lib/Db/WidgetPlacement.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@
8282
* @method void setAcknowledgementContentVersion(int $acknowledgementContentVersion)
8383
* @method string|null getAnnouncementKey()
8484
* @method void setAnnouncementKey(?string $announcementKey)
85+
* @method int|null getTemplatePlacementId()
86+
* @method void setTemplatePlacementId(?int $templatePlacementId)
8587
* @method string|null getCreatedAt()
8688
* @method void setCreatedAt(?string $createdAt)
8789
* @method string|null getUpdatedAt()
@@ -318,6 +320,20 @@ class WidgetPlacement extends Entity implements JsonSerializable
318320
*/
319321
protected ?string $announcementKey = null;
320322

323+
/**
324+
* The origin key linking this placement back to the source template's
325+
* blueprint placement it was cloned from (by that placement's `id`).
326+
* Null means "no known template origin" — either a genuinely
327+
* user-added placement, or a copy provisioned before this column
328+
* existed. Used by {@see \OCA\LaunchPad\Service\TemplateResyncService}
329+
* to distinguish template-origin placements (reconciled on re-sync)
330+
* from user-added ones (preserved under the `merge` strategy).
331+
* REQ-RESYNC-003 / REQ-RESYNC-004.
332+
*
333+
* @var integer|null
334+
*/
335+
protected ?int $templatePlacementId = null;
336+
321337
/**
322338
* The creation timestamp as string.
323339
*
@@ -360,6 +376,7 @@ public function __construct()
360376
$this->addType(fieldName: 'reacknowledgeOnChange', type: 'integer');
361377
// SMALLINT in DB (0/1).
362378
$this->addType(fieldName: 'acknowledgementContentVersion', type: 'integer');
379+
$this->addType(fieldName: 'templatePlacementId', type: 'integer');
363380
}//end __construct()
364381

365382
/**
@@ -478,6 +495,7 @@ public function jsonSerialize(): array
478495
'reacknowledgeOnChange' => $this->reacknowledgeOnChange,
479496
'acknowledgementContentVersion' => $this->acknowledgementContentVersion,
480497
'announcementKey' => $this->announcementKey,
498+
'templatePlacementId' => $this->templatePlacementId,
481499
'createdAt' => $this->createdAt,
482500
'updatedAt' => $this->updatedAt,
483501
];

0 commit comments

Comments
 (0)