Skip to content

Commit 71e6d64

Browse files
authored
feat: Record of Review (#2268)
Labs / beta features: - Opt-in system: config/features.php catalog, FeatureOptIn mutation, User feature_opt_ins, UserPolicy, beta-users admin page. Gated client-side via useFeatures/isFeatureEnabled and route meta.feature. No access gate on the page itself by design. - Labs page with theme-aware feature previews and Fider feedback link. Record of Review: - Account page listing review assignments; per-record and combined/zip HTML export, self-contained (inlined images, absolute links). Download-format dialog + shared DialogTitle atom. Participant identity card. - completion-date selection hardened; audit fields trimmed. Tables / infra: - Hoist grid view + sort menu into QueryTable; enabled gate and URL sync in usePaginatedQuery. Extract useSubmissionFilters composable. - Migrate account pages to file-based routes. - Seed a realistic review team for demo data. Docs: developers/beta-features.md.
1 parent 11b2950 commit 71e6d64

57 files changed

Lines changed: 4900 additions & 888 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/config/features.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@
3232
* answer hasFeatureEnabled().
3333
*/
3434
'beta' => [
35-
// No beta features are currently active. Add a key here (and a
36-
// matching Labs child route on the client) to introduce one.
35+
// Record of Review: the per-reviewer history page at
36+
// /account/record-of-review and its header/account-menu links.
37+
// Gated client-side via isFeatureEnabled('record_of_review').
38+
'record_of_review',
3739
],
3840
];

backend/database/seeders/SubmissionSeeder.php

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ public function run()
2525
$this->callOnce(PublicationSeeder::class);
2626
$this->callOnce(UserSeeder::class);
2727

28+
config(['audit.console' => true]);
29+
2830
$this->createSubmission(100, 'Pilcrow Test Submission 1')
2931
->update(['updated_by' => 1, 'status' => Submission::UNDER_REVIEW]);
3032

@@ -61,6 +63,73 @@ public function run()
6163

6264
$this->createSubmissionWithUpload(112, 'Endnote Test')
6365
->update(['updated_by' => 1, 'status' => Submission::UNDER_REVIEW]);
66+
67+
$this->createSubmissionWithTeam(
68+
114,
69+
'Methodological Tradeoffs in Cross-Cultural Survey Design',
70+
submitter: 'publicationAdministrator',
71+
coordinators: ['naomiOkafor'],
72+
reviewers: ['regularUser', 'hiroshiTanaka', 'priyaRamanathan'],
73+
status: Submission::ACCEPTED_AS_FINAL,
74+
);
75+
}
76+
77+
/**
78+
* Create a submission with explicit team assignments and a final status.
79+
*
80+
* Two saves occur: first as DRAFT (so the audit log records a status
81+
* transition into the final state), then the targeted status.
82+
*
83+
* @param array<string> $coordinators usernames assigned as review coordinators
84+
* @param array<string> $reviewers usernames assigned as reviewers
85+
*/
86+
protected function createSubmissionWithTeam(
87+
int $id,
88+
string $title,
89+
string $submitter,
90+
array $coordinators,
91+
array $reviewers,
92+
int $status,
93+
): Submission {
94+
$submitterUser = User::firstWhere('username', $submitter);
95+
96+
$factory = Submission::factory()
97+
->hasAttached($submitterUser, [], 'submitters')
98+
->has(SubmissionContent::factory()->count(3), 'contentHistory');
99+
100+
foreach ($coordinators as $username) {
101+
$factory = $factory->hasAttached(
102+
User::firstWhere('username', $username),
103+
[],
104+
'reviewCoordinators'
105+
);
106+
}
107+
108+
foreach ($reviewers as $username) {
109+
$factory = $factory->hasAttached(
110+
User::firstWhere('username', $username),
111+
[],
112+
'reviewers'
113+
);
114+
}
115+
116+
$submission = $factory->create([
117+
'id' => $id,
118+
'title' => $title,
119+
'publication_id' => 1,
120+
'created_by' => $submitterUser->id,
121+
'updated_by' => $submitterUser->id,
122+
'status' => Submission::DRAFT,
123+
]);
124+
125+
$submission->content()->associate($submission->contentHistory->last());
126+
$submission->status = $status;
127+
// Force `updated_by` to a different value than `created_by` so the
128+
// CreatedUpdatedBy trait sees it as dirty and skips its `auth()` path.
129+
$submission->updated_by = $submitterUser->id === 1 ? 2 : 1;
130+
$submission->save();
131+
132+
return $submission;
64133
}
65134

66135
/**

backend/database/seeders/UserSeeder.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,46 +17,88 @@ class UserSeeder extends Seeder
1717
*/
1818
public function run()
1919
{
20+
$orcid = fn(string $id) => [
21+
'academic_profiles' => ['orcid_id' => $id],
22+
];
23+
2024
User::factory()->create([
2125
'username' => 'applicationAdminUser',
2226
'email' => 'applicationAdministrator@meshresearch.net',
2327
'name' => 'Application Administrator',
2428
'password' => Hash::make('adminPassword!@#'),
29+
'profile_metadata' => $orcid('0000-0002-1825-0097'),
2530
])->assignRole(Role::APPLICATION_ADMINISTRATOR);
2631

2732
User::factory()->create([
2833
'username' => 'publicationAdministrator',
2934
'email' => 'publicationAdministrator@meshresearch.net',
3035
'name' => 'Publication Administrator',
3136
'password' => Hash::make('publicationadminPassword!@#'),
37+
'profile_metadata' => $orcid('0000-0001-5109-3700'),
3238
]);
3339

3440
User::factory()->create([
3541
'username' => 'publicationEditor',
3642
'email' => 'publicationEditor@meshresearch.net',
3743
'name' => 'Publication Editor',
3844
'password' => Hash::make('editorPassword!@#'),
45+
'profile_metadata' => $orcid('0000-0002-1694-233X'),
3946
]);
4047

4148
User::factory()->create([
4249
'username' => 'reviewCoordinator',
4350
'email' => 'reviewCoordinator@meshresearch.net',
4451
'name' => 'Review Coordinator for Submission',
4552
'password' => Hash::make('coordinatorPassword!@#'),
53+
'profile_metadata' => $orcid('0000-0002-7099-2346'),
4654
]);
4755

4856
User::factory()->create([
4957
'username' => 'reviewer',
5058
'email' => 'reviewer@meshresearch.net',
5159
'name' => 'Reviewer for Submission',
5260
'password' => Hash::make('reviewerPassword!@#'),
61+
'profile_metadata' => $orcid('0000-0003-1234-5674'),
5362
]);
5463

5564
User::factory()->create([
5665
'username' => 'regularUser',
5766
'email' => 'regularuser@meshresearch.net',
5867
'name' => 'Regular User',
5968
'password' => Hash::make('regularPassword!@#'),
69+
'profile_metadata' => $orcid('0000-0002-8765-4327'),
70+
]);
71+
72+
User::factory()->create([
73+
'username' => 'naomiOkafor',
74+
'email' => 'naomi.okafor@meshresearch.net',
75+
'name' => 'Naomi Okafor',
76+
'password' => Hash::make('regularPassword!@#'),
77+
'profile_metadata' => $orcid('0000-0001-7421-9038'),
78+
]);
79+
80+
User::factory()->create([
81+
'username' => 'leaMarchetti',
82+
'email' => 'lea.marchetti@meshresearch.net',
83+
'name' => 'Léa Marchetti',
84+
'password' => Hash::make('regularPassword!@#'),
85+
'profile_metadata' => $orcid('0000-0003-2154-6781'),
86+
]);
87+
88+
User::factory()->create([
89+
'username' => 'hiroshiTanaka',
90+
'email' => 'hiroshi.tanaka@meshresearch.net',
91+
'name' => 'Hiroshi Tanaka',
92+
'password' => Hash::make('regularPassword!@#'),
93+
'profile_metadata' => $orcid('0000-0002-4498-1126'),
94+
]);
95+
96+
User::factory()->create([
97+
'username' => 'priyaRamanathan',
98+
'email' => 'priya.ramanathan@meshresearch.net',
99+
'name' => 'Priya Ramanathan',
100+
'password' => Hash::make('regularPassword!@#'),
101+
'profile_metadata' => $orcid('0000-0001-9032-7714'),
60102
]);
61103

62104
User::factory()->beta()->create([
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Tests\Feature;
5+
6+
use App\Models\Publication;
7+
use App\Models\Submission;
8+
use App\Models\User;
9+
use Illuminate\Foundation\Testing\RefreshDatabase;
10+
use Tests\TestCase;
11+
12+
/**
13+
* The Record of Review derives its "review completed" date from an audit
14+
* entry whose new_values.status is a post-review state. That audit is only
15+
* written when a real status-changing save fires the OwenIt auditing
16+
* observer, which is disabled for console runs by default (audit.console).
17+
*
18+
* These tests pin that load-bearing behaviour: a status change made while
19+
* console auditing is enabled produces the audit row the feature reads.
20+
*/
21+
class SubmissionStatusAuditTest extends TestCase
22+
{
23+
use RefreshDatabase;
24+
25+
/**
26+
* A status-changing save records an audit whose new_values.status holds
27+
* the new (post-review) status.
28+
*
29+
* @return void
30+
*/
31+
public function test_status_change_records_audit_with_new_status(): void
32+
{
33+
config(['audit.console' => true]);
34+
35+
// CreatedUpdatedBy reads auth()->user()->id on create/update.
36+
$this->actingAs(User::factory()->create());
37+
38+
$submission = Submission::factory()
39+
->for(Publication::factory()->create())
40+
->create(['status' => Submission::DRAFT]);
41+
42+
$submission->status = Submission::ACCEPTED_AS_FINAL;
43+
$submission->save();
44+
45+
$statuses = $submission->audits
46+
->pluck('new_values')
47+
->map(fn($values) => $values['status'] ?? null)
48+
->filter(fn($status) => $status !== null);
49+
50+
$this->assertTrue(
51+
$statuses->contains(Submission::ACCEPTED_AS_FINAL),
52+
'Expected an audit recording the transition into ACCEPTED_AS_FINAL.'
53+
);
54+
}
55+
56+
/**
57+
* Sanity check on the guard: with console auditing disabled (the default),
58+
* the seeder/test reliance on enabling it is justified because no audit is
59+
* written for a console-driven status change.
60+
*
61+
* @return void
62+
*/
63+
public function test_status_change_writes_no_audit_when_console_disabled(): void
64+
{
65+
config(['audit.console' => false]);
66+
67+
$this->actingAs(User::factory()->create());
68+
69+
$submission = Submission::factory()
70+
->for(Publication::factory()->create())
71+
->create(['status' => Submission::DRAFT]);
72+
73+
$submission->status = Submission::ACCEPTED_AS_FINAL;
74+
$submission->save();
75+
76+
$this->assertCount(0, $submission->audits);
77+
}
78+
}

client/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"private": true,
99
"scripts": {
1010
"lint": "eslint -c ./eslint.config.js",
11+
"lint:fix": "eslint --fix -c ./eslint.config.js",
1112
"format": "prettier --write \"**/*.{js,ts,vue,scss,html,md,json}\" --ignore-path .gitignore",
1213
"test": "echo \"See package.json => scripts for available tests.\" && exit 0",
1314
"push:cdn": "f() { s3cmd del $1 --recursive --force && s3cmd put -rP --no-mime-magic --guess-mime-type ./dist/spa/ $1;}; f",
@@ -43,6 +44,7 @@
4344
"graphql": "^16.6.0",
4445
"graphql-tag": "^2.12.6",
4546
"javascript-time-ago": "^2.5.9",
47+
"jszip": "^3.10.1",
4648
"lodash": "^4.17.21",
4749
"luxon": "^3.3.0",
4850
"quasar": "^2.6.0",
@@ -64,6 +66,7 @@
6466
"@intlify/eslint-plugin-vue-i18n": "^4.5.0",
6567
"@quasar/app-vite": "^2.0.0",
6668
"@types/lodash": "^4.17.24",
69+
"@types/luxon": "^3.7.1",
6770
"@types/validator": "^13.15.10",
6871
"@vitest/coverage-v8": "^4.0.0",
6972
"@vitest/eslint-plugin": "^1.2.2",
184 KB
Loading
179 KB
Loading
129 KB
Loading

client/src/components/AppFooter.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<component :is="versionUrl ? 'a' : 'span'" :href="versionUrl">
1212
{{ version }}
1313
</component>
14-
<q-tooltip v-if="parsedDate && !parsedDate.invalid">
14+
<q-tooltip v-if="parsedDate && parsedDate.isValid">
1515
{{ parsedDate.toFormat("dd-LLL-yyyy T") }} ({{ versionAge }})
1616
</q-tooltip>
1717
</span>
@@ -35,7 +35,7 @@ const parsedDate = computed(() =>
3535
versionDate ? DateTime.fromISO(versionDate) : undefined
3636
)
3737
const versionAge = computed(() =>
38-
parsedDate.value && !parsedDate.value.invalid
38+
parsedDate.value && parsedDate.value.isValid
3939
? timeAgo.format(parsedDate.value.toJSDate(), "long")
4040
: ""
4141
)

client/src/components/AppHeader.vue

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,18 @@
6262
{{ $t("settings.page_title") }}
6363
</q-item-section>
6464
</q-item>
65+
<q-item
66+
v-if="recordOfReviewEnabled"
67+
clickable
68+
:to="{ name: 'account:record_of_review' }"
69+
>
70+
<q-item-section avatar>
71+
<q-icon name="description" />
72+
</q-item-section>
73+
<q-item-section>{{
74+
$t("record_of_review.title")
75+
}}</q-item-section>
76+
</q-item>
6577
<div v-if="isAppAdmin">
6678
<q-separator />
6779
<q-item :to="{ name: 'admin:dashboard' }">
@@ -156,6 +168,7 @@
156168
import { useMagicKeys } from "@vueuse/core"
157169
import NotificationPopup from "src/components/molecules/NotificationPopup.vue"
158170
import { useCurrentUser } from "src/use/user"
171+
import { useFeatures } from "src/use/features"
159172
import { defineAsyncComponent, watchEffect } from "vue"
160173
import { useQuasar } from "quasar"
161174
import { useI18n } from "vue-i18n"
@@ -174,6 +187,8 @@ interface Props {
174187
defineProps<Props>()
175188
176189
const { currentUser, isAppAdmin } = useCurrentUser()
190+
const { isFeatureEnabled } = useFeatures()
191+
const recordOfReviewEnabled = isFeatureEnabled("record_of_review")
177192
const { locale } = useI18n({ useScope: "global" })
178193
const { ctrl, shift, alt, t } = useMagicKeys()
179194

0 commit comments

Comments
 (0)