From 7bb24faa0e34f74a51acd77dc32f3a52145394e8 Mon Sep 17 00:00:00 2001 From: Artem Savchenko Date: Thu, 16 Jul 2026 11:39:30 +0700 Subject: [PATCH 1/4] Support training reminders Signed-off-by: Artem Savchenko --- common/config/rush/pnpm-lock.yaml | 28 +- models/calendar/src/index.ts | 14 +- models/notification/src/index.ts | 18 +- models/server-notification/src/index.ts | 8 + models/server-training/src/index.ts | 23 ++ models/training/package.json | 2 + models/training/src/index.ts | 76 +++++- models/training/src/roles.ts | 6 +- models/training/src/types.ts | 32 +++ plugins/calendar/src/index.ts | 22 +- plugins/notification/src/index.ts | 27 +- plugins/training-assets/lang/cs.json | 11 +- plugins/training-assets/lang/de.json | 9 +- plugins/training-assets/lang/en.json | 10 +- plugins/training-assets/lang/es.json | 10 +- plugins/training-assets/lang/fr.json | 11 +- plugins/training-assets/lang/it.json | 9 +- plugins/training-assets/lang/ja.json | 9 +- plugins/training-assets/lang/ko.json | 211 ++++++++------- plugins/training-assets/lang/pl.json | 9 +- plugins/training-assets/lang/pt-br.json | 9 +- plugins/training-assets/lang/pt.json | 9 +- plugins/training-assets/lang/ru.json | 10 +- plugins/training-assets/lang/tr.json | 9 +- plugins/training-assets/lang/zh.json | 9 +- .../trainingRequestSendReminderAction.ts | 77 ++++++ .../ReminderSentNotification.svelte | 25 ++ .../src/components/Settings.svelte | 42 ++- plugins/training-resources/src/index.ts | 3 + plugins/training-resources/src/plugin.ts | 4 + .../src/utils/canSendTrainingReminder.ts | 28 ++ plugins/training/package.json | 2 + plugins/training/src/index.ts | 19 +- plugins/training/src/types.ts | 32 +++ .../notification-resources/src/index.ts | 66 ++++- server-plugins/notification/src/index.ts | 1 + .../training-resources/package.json | 2 + .../training-resources/src/index.ts | 6 + .../training-resources/src/triggers.ts | 248 ++++++++++++++++++ server-plugins/training/src/index.ts | 6 + .../src/__tests__/worker.test.ts | 95 ++++++- .../pod-events-processor/src/worker.ts | 99 +++++-- 42 files changed, 1191 insertions(+), 155 deletions(-) create mode 100644 plugins/training-resources/src/actions/trainingRequestSendReminderAction.ts create mode 100644 plugins/training-resources/src/components/ReminderSentNotification.svelte create mode 100644 plugins/training-resources/src/utils/canSendTrainingReminder.ts create mode 100644 server-plugins/training-resources/src/triggers.ts diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 53f25a986d..067662a8ea 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -13609,6 +13609,9 @@ importers: '@hcengineering/attachment': specifier: workspace:^0.7.0 version: link:../../plugins/attachment + '@hcengineering/calendar': + specifier: workspace:^0.7.0 + version: link:../../plugins/calendar '@hcengineering/contact': specifier: workspace:^0.7.0 version: link:../../plugins/contact @@ -13621,6 +13624,9 @@ importers: '@hcengineering/model-attachment': specifier: workspace:^0.7.0 version: link:../attachment + '@hcengineering/model-calendar': + specifier: workspace:^0.7.0 + version: link:../calendar '@hcengineering/model-contact': specifier: workspace:^0.7.0 version: link:../contact @@ -29448,12 +29454,18 @@ importers: '@hcengineering/attachment': specifier: workspace:^0.7.0 version: link:../attachment + '@hcengineering/calendar': + specifier: workspace:^0.7.0 + version: link:../calendar '@hcengineering/contact': specifier: workspace:^0.7.0 version: link:../contact '@hcengineering/core': specifier: workspace:^0.7.26 version: link:../../foundations/core/packages/core + '@hcengineering/notification': + specifier: workspace:^0.7.0 + version: link:../notification '@hcengineering/platform': specifier: workspace:^0.7.20 version: link:../../foundations/core/packages/platform @@ -35864,6 +35876,9 @@ importers: ../../server-plugins/training-resources: dependencies: + '@hcengineering/calendar': + specifier: workspace:^0.7.0 + version: link:../../plugins/calendar '@hcengineering/contact': specifier: workspace:^0.7.0 version: link:../../plugins/contact @@ -35876,6 +35891,9 @@ importers: '@hcengineering/platform': specifier: workspace:^0.7.20 version: link:../../foundations/core/packages/platform + '@hcengineering/server-contact': + specifier: workspace:^0.7.0 + version: link:../contact '@hcengineering/server-core': specifier: workspace:^0.7.19 version: link:../../foundations/server/packages/core @@ -48013,8 +48031,8 @@ packages: resolution: {integrity: sha512-jE8ugADnYOBsu1uaoayVl1tVKAMNOXyjwvv2U6udEA2ORBhDooJDWoGxTkhd4Qn4yh59JVVt/pKXtjPwx9OguQ==} hasBin: true - fast-xml-parser@5.3.5: - resolution: {integrity: sha512-JeaA2Vm9ffQKp9VjvfzObuMCjUYAp5WDYhRYL5LrBPY/jUDlUtOvDfot0vKSkB9tuX885BDHjtw4fZadD95wnA==} + fast-xml-parser@5.2.5: + resolution: {integrity: sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==} hasBin: true fastest-levenshtein@1.0.16: @@ -53239,7 +53257,7 @@ snapshots: '@aws-sdk/xml-builder@3.921.0': dependencies: '@smithy/types': 4.8.1 - fast-xml-parser: 5.3.5 + fast-xml-parser: 5.2.5 tslib: 2.8.1 '@aws/lambda-invoke-store@0.1.1': {} @@ -59691,7 +59709,7 @@ snapshots: dependencies: strnum: 1.1.2 - fast-xml-parser@5.3.5: + fast-xml-parser@5.2.5: dependencies: strnum: 2.1.2 @@ -62166,7 +62184,7 @@ snapshots: node-loader@2.0.0(webpack@5.102.1): dependencies: loader-utils: 2.0.4 - webpack: 5.102.1 + webpack: 5.102.1(esbuild@0.25.12)(webpack-cli@5.1.4) node-localstorage@2.2.1: dependencies: diff --git a/models/calendar/src/index.ts b/models/calendar/src/index.ts index 887a29048f..d8d488a4e2 100644 --- a/models/calendar/src/index.ts +++ b/models/calendar/src/index.ts @@ -20,6 +20,7 @@ import { type PrimaryCalendar, type Calendar, type CalendarEventPresenter, + type ReminderNotificationPresenter, type Event, type ExternalCalendar, type ReccuringEvent, @@ -63,7 +64,8 @@ import attachment from '@hcengineering/model-attachment' import contact from '@hcengineering/model-contact' import core, { TAttachedDoc, TClass, TDoc } from '@hcengineering/model-core' import view, { createAction } from '@hcengineering/model-view' -import notification from '@hcengineering/notification' +import notification, { type NotificationType } from '@hcengineering/notification' +import { type Asset, type IntlString } from '@hcengineering/platform' import setting from '@hcengineering/setting' import { type AnyComponent } from '@hcengineering/ui/src/types' import workbench from '@hcengineering/model-workbench' @@ -196,6 +198,15 @@ export class TCalendarEventPresenter extends TClass implements CalendarEventPres presenter!: AnyComponent } +@Mixin(calendar.mixin.ReminderNotificationPresenter, core.class.Class) +export class TReminderNotificationPresenter extends TClass implements ReminderNotificationPresenter { + redirectToAttached?: boolean + notificationType?: Ref + headerIcon?: Asset + header?: IntlString + message?: IntlString +} + export function createModel (builder: Builder): void { builder.createModel( TCalendar, @@ -205,6 +216,7 @@ export function createModel (builder: Builder): void { TEvent, TSchedule, TCalendarEventPresenter, + TReminderNotificationPresenter, TPrimaryCalendar ) diff --git a/models/notification/src/index.ts b/models/notification/src/index.ts index c9675d6c3d..65b1a27ce2 100644 --- a/models/notification/src/index.ts +++ b/models/notification/src/index.ts @@ -82,6 +82,7 @@ import { type NotificationTemplate, type NotificationType, type NotificationTypeSetting, + type OnDemandNotification, type PushSubscription, type PushSubscriptionKeys, type ReactionInboxNotification @@ -111,6 +112,20 @@ export class TBrowserNotification extends TDoc implements BrowserNotification { soundAlert!: boolean } +// Transient (never persisted): created by a client, consumed by the OnDemandNotification trigger. +@Model(notification.class.OnDemandNotification, core.class.Doc, DOMAIN_TRANSIENT) +export class TOnDemandNotification extends TDoc implements OnDemandNotification { + targets!: AccountUuid[] + objectId!: Ref + objectClass!: Ref> + objectSpace!: Ref + notificationType!: Ref + header?: IntlString + message?: IntlString + messageHtml?: Markup + icon?: Asset +} + @Model(notification.class.PushSubscription, core.class.Doc, DOMAIN_USER_NOTIFY) export class TPushSubscription extends TDoc implements PushSubscription { user!: AccountUuid @@ -371,7 +386,8 @@ export function createModel (builder: Builder): void { TNotificationProviderSetting, TNotificationTypeSetting, TNotificationProviderDefaults, - TReactionInboxNotification + TReactionInboxNotification, + TOnDemandNotification ) builder.mixin(notification.class.BrowserNotification, core.class.Class, core.mixin.TransientConfiguration, { diff --git a/models/server-notification/src/index.ts b/models/server-notification/src/index.ts index 858ed164d3..73f067ad07 100644 --- a/models/server-notification/src/index.ts +++ b/models/server-notification/src/index.ts @@ -81,6 +81,14 @@ export function createModel (builder: Builder): void { } }) + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverNotification.trigger.OnDemandNotificationSend, + txMatch: { + _class: core.class.TxCreateDoc, + objectClass: notification.class.OnDemandNotification + } + }) + builder.createDoc(serverCore.class.Trigger, core.space.Model, { trigger: serverNotification.trigger.OnEmployeeDeactivate, isAsync: true, diff --git a/models/server-training/src/index.ts b/models/server-training/src/index.ts index 353cff6404..d28f56ce4f 100644 --- a/models/server-training/src/index.ts +++ b/models/server-training/src/index.ts @@ -5,6 +5,7 @@ import { type Builder } from '@hcengineering/model' import training from '@hcengineering/model-training' import serverTraining from '@hcengineering/server-training' +import serverCore from '@hcengineering/server-core' import core from '@hcengineering/core' import notification from '@hcengineering/notification' import serverNotification from '@hcengineering/server-notification' @@ -12,6 +13,28 @@ import serverNotification from '@hcengineering/server-notification' export { serverTrainingId } from '@hcengineering/server-training/src/index' export function createModel (builder: Builder): void { + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverTraining.trigger.OnTrainingRequest, + txMatch: { + objectClass: training.class.TrainingRequest + } + }) + + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverTraining.trigger.OnTrainingAttempt, + txMatch: { + objectClass: training.class.TrainingAttempt + } + }) + + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverTraining.trigger.OnTrainingReminderSettings, + txMatch: { + _class: core.class.TxMixin, + mixin: training.mixin.TrainingReminderSettings + } + }) + builder.mixin( training.notification.TrainingRequest, notification.class.NotificationType, diff --git a/models/training/package.json b/models/training/package.json index dd0ae31ecf..29c83387bb 100644 --- a/models/training/package.json +++ b/models/training/package.json @@ -42,10 +42,12 @@ "@hcengineering/training-resources": "workspace:^0.7.0", "@hcengineering/activity": "workspace:^0.7.0", "@hcengineering/attachment": "workspace:^0.7.0", + "@hcengineering/calendar": "workspace:^0.7.0", "@hcengineering/contact": "workspace:^0.7.0", "@hcengineering/core": "workspace:^0.7.26", "@hcengineering/model": "workspace:^0.7.17", "@hcengineering/model-attachment": "workspace:^0.7.0", + "@hcengineering/model-calendar": "workspace:^0.7.0", "@hcengineering/model-contact": "workspace:^0.7.0", "@hcengineering/model-core": "workspace:^0.7.0", "@hcengineering/model-notification": "workspace:^0.7.0", diff --git a/models/training/src/index.ts b/models/training/src/index.ts index 449846c00f..f1ae090cff 100644 --- a/models/training/src/index.ts +++ b/models/training/src/index.ts @@ -14,6 +14,7 @@ // import activity from '@hcengineering/activity' +import calendar from '@hcengineering/calendar' import notification, { type NotificationType } from '@hcengineering/notification' import { type Asset, type IntlString } from '@hcengineering/platform' import type { BuildModelKey, KeyFilterPreset, Viewlet, ViewletDescriptor } from '@hcengineering/view' @@ -50,6 +51,8 @@ import { TTraining, TTrainingAttempt, TTrainingAttemptState, + TTrainingDeadlineEvent, + TTrainingReminderSettings, TTrainingRequest, TTrainingState, TTrainingsTypeData @@ -107,6 +110,7 @@ function defineSpaceType (builder: Builder): void { } builder.createModel(TTrainingsTypeData) + builder.createModel(TTrainingReminderSettings) builder.createDoc( core.class.SpaceTypeDescriptor, @@ -124,7 +128,8 @@ function defineSpaceType (builder: Builder): void { training.permission.CreateRequestOnSomeoneElsesTraining, training.permission.ChangeSomeoneElsesSentRequestOwner, training.permission.ViewSomeoneElsesSentRequest, - training.permission.ViewSomeoneElsesTraineesResults + training.permission.ViewSomeoneElsesTraineesResults, + training.permission.SendTrainingRequestReminder ] }, training.spaceTypeDescriptor.Trainings @@ -582,6 +587,21 @@ function defineTrainingRequest (builder: Builder): void { training.action.TrainingRequestCancel ) + createAction( + builder, + { + action: training.action.TrainingRequestSendReminderAction, + visibilityTester: training.action.TrainingRequestSendReminderIsAvailable, + input: 'focus', + label: training.string.SendReminder, + icon: training.icon.TrainingRequest, + target: training.class.TrainingRequest, + context: { mode: ['context'], group: 'tools' }, + category: training.actionCategory.Training + }, + training.action.TrainingRequestSendReminder + ) + builder.mixin(training.class.TrainingRequest, core.class.Class, activity.mixin.ActivityDoc, {}) builder.mixin(training.class.TrainingRequest, core.class.Class, view.mixin.ObjectTitle, { @@ -613,6 +633,60 @@ function defineTrainingRequest (builder: Builder): void { }, training.notification.TrainingRequest ) + + // Deadline reminder: the actual notification is materialized by the events-processor worker + // (via a direct createDoc), but the type + provider defaults still declare which channels it + // supports and let users toggle them per-type. Mirrors calendar's ReminderNotification. + builder.createModel(TTrainingDeadlineEvent) + + // Declaratively tell the events-processor how to present this event's reminders: point them at the + // TrainingRequest and use the training deadline notification type/content. No pod-side code needed. + builder.mixin(training.class.TrainingDeadlineEvent, core.class.Class, calendar.mixin.ReminderNotificationPresenter, { + redirectToAttached: true, + notificationType: training.ids.TrainingDeadlineReminder, + headerIcon: training.icon.TrainingRequest, + header: training.string.TrainingDeadlineReminder, + message: training.string.TrainingDeadlineReminder + }) + + builder.createDoc( + notification.class.NotificationType, + core.space.Model, + { + hidden: false, + generated: false, + allowedForAuthor: true, + label: training.string.TrainingDeadlineReminder, + group: training.notification.TrainingGroup, + txClasses: [core.class.TxCreateDoc], + objectClass: training.class.TrainingRequest, + defaultEnabled: true, + templates: { + textTemplate: 'Training {doc} is due soon', + htmlTemplate: '

Training {doc} is due soon

', + subjectTemplate: 'Training deadline: {doc}' + } + }, + training.ids.TrainingDeadlineReminder + ) + + builder.createDoc(notification.class.NotificationProviderDefaults, core.space.Model, { + provider: notification.providers.InboxNotificationProvider, + ignoredTypes: [], + enabledTypes: [training.ids.TrainingDeadlineReminder] + }) + + builder.createDoc(notification.class.NotificationProviderDefaults, core.space.Model, { + provider: notification.providers.PushNotificationProvider, + ignoredTypes: [], + enabledTypes: [training.ids.TrainingDeadlineReminder] + }) + + definePermission(builder, training.permission.SendTrainingRequestReminder, { + label: training.string.Permission_SendTrainingRequestReminder, + scope: 'space', + description: training.string.Permission_SendTrainingRequestReminder_Description + }) } function defineTrainingAttempt (builder: Builder): void { diff --git a/models/training/src/roles.ts b/models/training/src/roles.ts index e063932378..fffe296d44 100644 --- a/models/training/src/roles.ts +++ b/models/training/src/roles.ts @@ -26,7 +26,8 @@ export const roles = [ training.permission.CreateRequestOnSomeoneElsesTraining, training.permission.ChangeSomeoneElsesSentRequestOwner, training.permission.ViewSomeoneElsesSentRequest, - training.permission.ViewSomeoneElsesTraineesResults + training.permission.ViewSomeoneElsesTraineesResults, + training.permission.SendTrainingRequestReminder ] }, { @@ -38,7 +39,8 @@ export const roles = [ training.permission.ViewSomeoneElsesTrainingQuestions, training.permission.CreateRequestOnSomeoneElsesTraining, training.permission.ViewSomeoneElsesSentRequest, - training.permission.ViewSomeoneElsesTraineesResults + training.permission.ViewSomeoneElsesTraineesResults, + training.permission.SendTrainingRequestReminder ] }, { diff --git a/models/training/src/types.ts b/models/training/src/types.ts index b06f61bc4d..de6037f9fb 100644 --- a/models/training/src/types.ts +++ b/models/training/src/types.ts @@ -21,9 +21,13 @@ import { type Training, type TrainingAttempt, TrainingAttemptState, + type TrainingDeadlineEvent, + type TrainingReminderSettings, type TrainingRequest, TrainingState } from '@hcengineering/training' +import calendar from '@hcengineering/calendar' +import { TEvent } from '@hcengineering/model-calendar' import contact, { type Employee } from '@hcengineering/contact' import core, { type Class, @@ -264,3 +268,31 @@ export class TTrainingAttempt extends TAttachedDoc implements TrainingAttempt { export class TTrainingsTypeData extends TTypedSpace implements RolesAssignment { [key: Ref]: AccountUuid[] } + +/** + * Per-space configuration for training deadline reminders. + * Kept as its own mixin (rather than a field on {@link TTrainingsTypeData}, whose `RolesAssignment` + * index signature forbids non-`AccountUuid[]` fields). + */ +@Mixin(training.mixin.TrainingReminderSettings, core.class.TypedSpace) +@UX(getEmbeddedLabel('Training reminders'), training.icon.TrainingApplication) +export class TTrainingReminderSettings extends TTypedSpace implements TrainingReminderSettings { + @Prop(ArrOf(TypeNumber()), training.string.TrainingReminderOffsetsDays) + reminderOffsetsDays: number[] = [30, 7, 1] +} + +/** + * A training deadline materialized as a calendar event (one per trainee of a request). + * Subclasses `calendar.Event`, so the existing `OnEvent` trigger schedules its reminders. + */ +@Model(training.class.TrainingDeadlineEvent, calendar.class.Event) +@UX(training.string.TrainingDeadlineReminder, training.icon.TrainingRequest) +export class TTrainingDeadlineEvent extends TEvent implements TrainingDeadlineEvent { + @Prop(TypeRef(training.class.TrainingRequest), training.string.TrainingRequest) + @Index(IndexKind.Indexed) + request!: Ref + + @Prop(TypeRef(contact.mixin.Employee), training.string.TrainingRequestTrainee) + @Index(IndexKind.Indexed) + trainee!: Ref +} diff --git a/plugins/calendar/src/index.ts b/plugins/calendar/src/index.ts index f85c49abd3..ad62a6cd91 100644 --- a/plugins/calendar/src/index.ts +++ b/plugins/calendar/src/index.ts @@ -158,6 +158,25 @@ export interface CalendarEventPresenter extends Class { presenter: AnyComponent } +/** + * Declarative, per-event-class customization of the reminder notification produced by the + * events-processor. Attach this mixin to any `Event` subclass to override how its reminders are + * delivered — without the events-processor needing to depend on that subclass's plugin. + * + * All fields are optional; unset fields fall back to the generic calendar reminder behaviour. + * + * @public + */ +export interface ReminderNotificationPresenter extends Class { + /** Point the reminder at the event's attached parent (`attachedTo`/`attachedToClass`) rather than the event. */ + redirectToAttached?: boolean + /** NotificationType to file the reminder under (drives per-channel settings). */ + notificationType?: Ref + headerIcon?: Asset + header?: IntlString + message?: IntlString +} + export type ScheduleAvailability = Record /** @@ -203,7 +222,8 @@ const calendarPlugin = plugin(calendarId, { PrimaryCalendar: '' as Ref> }, mixin: { - CalendarEventPresenter: '' as Ref> + CalendarEventPresenter: '' as Ref>, + ReminderNotificationPresenter: '' as Ref> }, icon: { Calendar: '' as Asset, diff --git a/plugins/notification/src/index.ts b/plugins/notification/src/index.ts index 767125c41c..10e1e82c6f 100644 --- a/plugins/notification/src/index.ts +++ b/plugins/notification/src/index.ts @@ -303,6 +303,30 @@ export interface DocNotifyContext extends Doc { tx?: Ref> } +/** + * A generic, domain-agnostic request to deliver an inbox notification to a set of users on demand. + * + * Created by any client (in a space the caller can write to); a server trigger fans it out into each + * target's private space with system trust, then the doc is discarded (it lives in `DOMAIN_TRANSIENT`, + * so it is never persisted). Reusable by any module that needs a "notify these users now" button. + * + * @public + */ +export interface OnDemandNotification extends Doc { + /** Recipients, as account uuids (e.g. `Employee.personUuid`). */ + targets: AccountUuid[] + /** The doc the notification points at (its click target). */ + objectId: Ref + objectClass: Ref> + objectSpace: Ref + /** The NotificationType to file under (drives per-channel settings). */ + notificationType: Ref + header?: IntlString + message?: IntlString + messageHtml?: Markup + icon?: Asset +} + /** * @public */ @@ -370,7 +394,8 @@ const notification = plugin(notificationId, { NotificationTypeSetting: '' as Ref>, NotificationProviderSetting: '' as Ref>, NotificationProviderDefaults: '' as Ref>, - ReactionInboxNotification: '' as Ref> + ReactionInboxNotification: '' as Ref>, + OnDemandNotification: '' as Ref> }, ids: { NotificationSettings: '' as Ref, diff --git a/plugins/training-assets/lang/cs.json b/plugins/training-assets/lang/cs.json index e56fd73ec0..bfc89c24b4 100644 --- a/plugins/training-assets/lang/cs.json +++ b/plugins/training-assets/lang/cs.json @@ -99,6 +99,13 @@ "ViewSentRequests": "Žádosti školených", "ViewTraineesResults": "Výsledky školených", "ConfigLabel": "Školení", - "ConfigDescription": "Rozšíření pro správu školení" + "ConfigDescription": "Rozšíření pro správu školení", + "TrainingDeadlineReminder": "Připomenutí termínu školení", + "TrainingReminderOffsetsDays": "Předstihy připomenutí (dny)", + "Permission_SendTrainingRequestReminder": "Odeslat připomenutí školení", + "Permission_SendTrainingRequestReminder_Description": "Odesílat následná připomenutí účastníkům, kteří nedokončili žádost o školení", + "SendReminder": "Odeslat připomenutí", + "ConfirmSendReminder": "Odeslat připomenutí {count} účastníkům, kteří nedokončili toto školení?", + "ReminderSent": "Připomenutí odesláno {count} účastníkům" } -} \ No newline at end of file +} diff --git a/plugins/training-assets/lang/de.json b/plugins/training-assets/lang/de.json index 515c87ef16..c7ae7aba43 100644 --- a/plugins/training-assets/lang/de.json +++ b/plugins/training-assets/lang/de.json @@ -99,6 +99,13 @@ "ViewSentRequests": "Teilnehmer-Anfragen", "ViewTraineesResults": "Teilnehmer-Ergebnisse", "ConfigLabel": "Schulungen", - "ConfigDescription": "Erweiterung zur Verwaltung von Schulungen" + "ConfigDescription": "Erweiterung zur Verwaltung von Schulungen", + "TrainingDeadlineReminder": "Erinnerung an Schulungsfrist", + "TrainingReminderOffsetsDays": "Erinnerungsvorlaufzeiten (Tage)", + "Permission_SendTrainingRequestReminder": "Schulungserinnerung senden", + "Permission_SendTrainingRequestReminder_Description": "Folgeerinnerungen an Teilnehmer senden, die eine Schulungsanfrage nicht abgeschlossen haben", + "SendReminder": "Erinnerung senden", + "ConfirmSendReminder": "Erinnerung an {count} Teilnehmer senden, die diese Schulung nicht abgeschlossen haben?", + "ReminderSent": "Erinnerung an {count} Teilnehmer gesendet" } } diff --git a/plugins/training-assets/lang/en.json b/plugins/training-assets/lang/en.json index 671c993fad..6bb63227fd 100644 --- a/plugins/training-assets/lang/en.json +++ b/plugins/training-assets/lang/en.json @@ -99,6 +99,14 @@ "ViewSentRequests": "Trainees' requests", "ViewTraineesResults": "Trainees' results", "ConfigLabel": "Trainings", - "ConfigDescription": "Extension to manage trainings" + "ConfigDescription": "Extension to manage trainings", + "Permission_SendTrainingRequestReminder": "Send training reminder", + "Permission_SendTrainingRequestReminder_Description": "Send follow-up reminders to trainees who have not completed a training request", + "TrainingDeadlineReminder": "Training deadline reminder", + "TrainingDeadlineReminderTitle": "Training is due soon", + "TrainingReminderOffsetsDays": "Reminder lead times (days)", + "SendReminder": "Send reminder", + "ConfirmSendReminder": "Send a reminder to {count} trainee(s) who haven't completed this training?", + "ReminderSent": "Reminder sent to {count} trainee(s)" } } diff --git a/plugins/training-assets/lang/es.json b/plugins/training-assets/lang/es.json index 41ff32d4c6..9f9cb9f91b 100644 --- a/plugins/training-assets/lang/es.json +++ b/plugins/training-assets/lang/es.json @@ -99,7 +99,13 @@ "ViewSentRequests": "Solicitudes de aprendices", "ViewTraineesResults": "Resultados de aprendices", "ConfigLabel": "Capacitaciones", - "ConfigDescription": "Extensión para gestionar capacitaciones" + "ConfigDescription": "Extensión para gestionar capacitaciones", + "TrainingDeadlineReminder": "Recordatorio de fecha límite de formación", + "TrainingReminderOffsetsDays": "Antelaciones de recordatorio (días)", + "Permission_SendTrainingRequestReminder": "Enviar recordatorio de formación", + "Permission_SendTrainingRequestReminder_Description": "Enviar recordatorios de seguimiento a los alumnos que no han completado una solicitud de formación", + "SendReminder": "Enviar recordatorio", + "ConfirmSendReminder": "¿Enviar un recordatorio a {count} alumno(s) que no han completado esta formación?", + "ReminderSent": "Recordatorio enviado a {count} alumno(s)" } } - diff --git a/plugins/training-assets/lang/fr.json b/plugins/training-assets/lang/fr.json index 42ed23e89b..767fb9f488 100644 --- a/plugins/training-assets/lang/fr.json +++ b/plugins/training-assets/lang/fr.json @@ -99,6 +99,13 @@ "ViewSentRequests": "Demandes des stagiaires", "ViewTraineesResults": "Résultats des stagiaires", "ConfigLabel": "Formations", - "ConfigDescription": "Extension pour gérer les formations" + "ConfigDescription": "Extension pour gérer les formations", + "TrainingDeadlineReminder": "Rappel d'échéance de formation", + "TrainingReminderOffsetsDays": "Délais de rappel (jours)", + "Permission_SendTrainingRequestReminder": "Envoyer un rappel de formation", + "Permission_SendTrainingRequestReminder_Description": "Envoyer des rappels de suivi aux apprenants qui n'ont pas terminé une demande de formation", + "SendReminder": "Envoyer un rappel", + "ConfirmSendReminder": "Envoyer un rappel à {count} apprenant(s) n'ayant pas terminé cette formation ?", + "ReminderSent": "Rappel envoyé à {count} apprenant(s)" } -} \ No newline at end of file +} diff --git a/plugins/training-assets/lang/it.json b/plugins/training-assets/lang/it.json index 48cf25b2d9..d1ba3a3f04 100644 --- a/plugins/training-assets/lang/it.json +++ b/plugins/training-assets/lang/it.json @@ -99,6 +99,13 @@ "ViewSentRequests": "Richieste dei partecipanti", "ViewTraineesResults": "Risultati dei partecipanti", "ConfigLabel": "Formazioni", - "ConfigDescription": "Estensione per gestire le formazioni" + "ConfigDescription": "Estensione per gestire le formazioni", + "TrainingDeadlineReminder": "Promemoria scadenza formazione", + "TrainingReminderOffsetsDays": "Anticipi promemoria (giorni)", + "Permission_SendTrainingRequestReminder": "Invia promemoria formazione", + "Permission_SendTrainingRequestReminder_Description": "Invia promemoria di follow-up ai partecipanti che non hanno completato una richiesta di formazione", + "SendReminder": "Invia promemoria", + "ConfirmSendReminder": "Inviare un promemoria a {count} partecipante/i che non hanno completato questa formazione?", + "ReminderSent": "Promemoria inviato a {count} partecipante/i" } } diff --git a/plugins/training-assets/lang/ja.json b/plugins/training-assets/lang/ja.json index ac029ab83b..dbb1d2da8b 100644 --- a/plugins/training-assets/lang/ja.json +++ b/plugins/training-assets/lang/ja.json @@ -99,6 +99,13 @@ "ViewSentRequests": "受講者のリクエスト", "ViewTraineesResults": "受講者の結果", "ConfigLabel": "トレーニング", - "ConfigDescription": "トレーニングを管理するための拡張機能" + "ConfigDescription": "トレーニングを管理するための拡張機能", + "TrainingDeadlineReminder": "研修期限のリマインダー", + "TrainingReminderOffsetsDays": "リマインダーの事前日数(日)", + "Permission_SendTrainingRequestReminder": "研修リマインダーを送信", + "Permission_SendTrainingRequestReminder_Description": "研修リクエストを完了していない受講者にフォローアップのリマインダーを送信する", + "SendReminder": "リマインダーを送信", + "ConfirmSendReminder": "この研修を完了していない{count}名の受講者にリマインダーを送信しますか?", + "ReminderSent": "{count}名の受講者にリマインダーを送信しました" } } diff --git a/plugins/training-assets/lang/ko.json b/plugins/training-assets/lang/ko.json index 380b3d0d11..8b0a08f017 100644 --- a/plugins/training-assets/lang/ko.json +++ b/plugins/training-assets/lang/ko.json @@ -1,104 +1,111 @@ { - "string": { - "Active": "활성", - "All": "전체", - "Canceled": "취소됨", - "ChangeOwner": "소유자 변경", - "Completed": "완료", - "CreateNewVersion": "새 버전 만들기", - "Duplicate": "복제", - "IncomingRequestStateCanceled": "취소됨", - "IncomingRequestStateDraft": "초안", - "IncomingRequestStateFailed": "불합격", - "IncomingRequestStatePassed": "합격", - "IncomingRequestStatePending": "대기 중", - "NotSelected": "선택 안 함", - "Owner": "소유자", - "Permission_ChangeSomeoneElsesSentRequestOwner": "다른 사람이 보낸 교육훈련 요청의 소유자 변경", - "Permission_ChangeSomeoneElsesSentRequestOwner_Description": "모든 사용자의 교육훈련 요청 소유자를 변경할 수 있는 권한", - "Permission_ChangeSomeoneElsesTrainingOwner": "다른 사람이 소유한 교육훈련의 소유자 변경", - "Permission_ChangeSomeoneElsesTrainingOwner_Description": "다른 사용자가 소유한 교육훈련의 소유자를 변경할 수 있는 권한", - "Permission_CreateRequestOnSomeoneElsesTraining": "다른 사람이 소유한 교육훈련 할당", - "Permission_CreateRequestOnSomeoneElsesTraining_Description": "다른 사용자가 소유한 교육훈련을 할당(요청 생성)할 수 있는 권한", - "Permission_CreateTraining": "교육훈련 만들기", - "Permission_CreateTraining_Description": "새 교육훈련을 만들 수 있는 권한", - "Permission_ViewSomeoneElsesSentRequest": "다른 사람이 보낸 교육훈련 요청 보기", - "Permission_ViewSomeoneElsesSentRequest_Description": "다른 사용자가 보낸 교육훈련 요청을 볼 수 있는 권한", - "Permission_ViewSomeoneElsesTraineesResults": "다른 사람이 보낸 교육훈련 요청의 수강생 결과 보기", - "Permission_ViewSomeoneElsesTraineesResults_Description": "다른 사용자가 보낸 교육훈련의 수강생 결과를 볼 수 있는 권한", - "Permission_ViewSomeoneElsesTrainingOverview": "다른 사람이 소유한 교육훈련 보기", - "Permission_ViewSomeoneElsesTrainingOverview_Description": "다른 사용자가 소유한 교육훈련을 볼 수 있는 권한", - "Permission_ViewSomeoneElsesTrainingQuestions": "다른 사람이 소유한 교육훈련의 질문 보기", - "Permission_ViewSomeoneElsesTrainingQuestions_Description": "다른 사용자가 소유한 교육훈련의 질문을 볼 수 있는 권한", - "Revision": "개정", - "SentYouATrainingRequest": "님이 교육훈련 요청을 보냈습니다", - "State": "상태", - "Training": "교육훈련", - "TrainingApplication": "교육훈련", - "TrainingAttempt": "시도", - "TrainingAttemptAssessmentsPassed": "합격한 평가", - "TrainingAttemptAssessmentsTotal": "전체 평가", - "TrainingAttemptMyCurrentDraft": "내 현재 초안", - "TrainingAttemptMyLatestResult": "내 최근 결과", - "TrainingAttempts": "시도", - "TrainingAttemptStateDraft": "초안", - "TrainingAttemptStateFailed": "불합격", - "TrainingAttemptStatePassed": "합격", - "TrainingAttemptSubmit": "제출", - "TrainingAttemptSubmittedBy": "제출자", - "TrainingAttemptSubmittedDate": "제출일", - "TrainingAuthor": "작성자", - "TrainingCreate": "교육훈련 만들기", - "TrainingCreated": "교육훈련이 생성되었습니다", - "TrainingOverview": "개요", - "TrainingPassingScore": "합격 점수", - "TrainingPrefix": "접두사", - "TrainingQuestions": "질문", - "TrainingRelease": "릴리스", - "TrainingReleasedBy": "릴리스 담당자", - "TrainingReleasedOn": "릴리스 날짜", - "TrainingRequest": "교육훈련 요청", - "TrainingRequestAssign": "요청 할당", - "TrainingRequestAttempt": "시도 {index}/{total}", - "TrainingRequestCancel": "요청 취소", - "TrainingRequestCanceledBy": "취소자", - "TrainingRequestCanceledDate": "취소일", - "TrainingRequestCompletion": "완료", - "TrainingRequestDueDate": "마감일", - "TrainingRequestMaxAttempts": "최대 시도 횟수", - "TrainingRequestNoDueDate": "마감일 없음", - "TrainingRequestResults": "결과", - "TrainingRequestRoles": "역할", - "TrainingRequestRolesCount": "{count, plural, one {역할 #개} other {역할 #개}}", - "TrainingRequests": "요청", - "TrainingRequestTrainee": "수강생", - "TrainingRequestTrainees": "수강생", - "TrainingRetake": "재시도", - "TrainingSeqNumber": "번호", - "TrainingStart": "교육훈련 시작", - "TrainingStateArchived": "보관됨", - "TrainingStateDeleted": "삭제됨", - "TrainingStateDraft": "초안", - "TrainingStateReleased": "릴리스됨", - "TrainingTake": "교육훈련 수강", - "AllowToTakeTraining": "교육훈련 수강 허용", - "TrainingTitle": "제목", - "Trainings": "교육훈련", - "ViewAllTrainings": "모든 교육훈련", - "ViewAllTrainingsAll": "전체", - "ViewAllTrainingsArchived": "보관됨", - "ViewAllTrainingsDrafts": "초안", - "ViewAllTrainingsReleased": "릴리스됨", - "ViewIncomingRequests": "내 요청", - "ViewMyResults": "내 결과", - "ViewMyTrainings": "내 교육훈련", - "ViewMyTrainingsAll": "전체", - "ViewMyTrainingsArchived": "보관됨", - "ViewMyTrainingsDrafts": "초안", - "ViewMyTrainingsReleased": "릴리스됨", - "ViewSentRequests": "수강생 요청", - "ViewTraineesResults": "수강생 결과", - "ConfigLabel": "교육훈련", - "ConfigDescription": "교육훈련을 관리하기 위한 확장 기능" - } + "string": { + "Active": "활성", + "All": "전체", + "Canceled": "취소됨", + "ChangeOwner": "소유자 변경", + "Completed": "완료", + "CreateNewVersion": "새 버전 만들기", + "Duplicate": "복제", + "IncomingRequestStateCanceled": "취소됨", + "IncomingRequestStateDraft": "초안", + "IncomingRequestStateFailed": "불합격", + "IncomingRequestStatePassed": "합격", + "IncomingRequestStatePending": "대기 중", + "NotSelected": "선택 안 함", + "Owner": "소유자", + "Permission_ChangeSomeoneElsesSentRequestOwner": "다른 사람이 보낸 교육훈련 요청의 소유자 변경", + "Permission_ChangeSomeoneElsesSentRequestOwner_Description": "모든 사용자의 교육훈련 요청 소유자를 변경할 수 있는 권한", + "Permission_ChangeSomeoneElsesTrainingOwner": "다른 사람이 소유한 교육훈련의 소유자 변경", + "Permission_ChangeSomeoneElsesTrainingOwner_Description": "다른 사용자가 소유한 교육훈련의 소유자를 변경할 수 있는 권한", + "Permission_CreateRequestOnSomeoneElsesTraining": "다른 사람이 소유한 교육훈련 할당", + "Permission_CreateRequestOnSomeoneElsesTraining_Description": "다른 사용자가 소유한 교육훈련을 할당(요청 생성)할 수 있는 권한", + "Permission_CreateTraining": "교육훈련 만들기", + "Permission_CreateTraining_Description": "새 교육훈련을 만들 수 있는 권한", + "Permission_ViewSomeoneElsesSentRequest": "다른 사람이 보낸 교육훈련 요청 보기", + "Permission_ViewSomeoneElsesSentRequest_Description": "다른 사용자가 보낸 교육훈련 요청을 볼 수 있는 권한", + "Permission_ViewSomeoneElsesTraineesResults": "다른 사람이 보낸 교육훈련 요청의 수강생 결과 보기", + "Permission_ViewSomeoneElsesTraineesResults_Description": "다른 사용자가 보낸 교육훈련의 수강생 결과를 볼 수 있는 권한", + "Permission_ViewSomeoneElsesTrainingOverview": "다른 사람이 소유한 교육훈련 보기", + "Permission_ViewSomeoneElsesTrainingOverview_Description": "다른 사용자가 소유한 교육훈련을 볼 수 있는 권한", + "Permission_ViewSomeoneElsesTrainingQuestions": "다른 사람이 소유한 교육훈련의 질문 보기", + "Permission_ViewSomeoneElsesTrainingQuestions_Description": "다른 사용자가 소유한 교육훈련의 질문을 볼 수 있는 권한", + "Revision": "개정", + "SentYouATrainingRequest": "님이 교육훈련 요청을 보냈습니다", + "State": "상태", + "Training": "교육훈련", + "TrainingApplication": "교육훈련", + "TrainingAttempt": "시도", + "TrainingAttemptAssessmentsPassed": "합격한 평가", + "TrainingAttemptAssessmentsTotal": "전체 평가", + "TrainingAttemptMyCurrentDraft": "내 현재 초안", + "TrainingAttemptMyLatestResult": "내 최근 결과", + "TrainingAttempts": "시도", + "TrainingAttemptStateDraft": "초안", + "TrainingAttemptStateFailed": "불합격", + "TrainingAttemptStatePassed": "합격", + "TrainingAttemptSubmit": "제출", + "TrainingAttemptSubmittedBy": "제출자", + "TrainingAttemptSubmittedDate": "제출일", + "TrainingAuthor": "작성자", + "TrainingCreate": "교육훈련 만들기", + "TrainingCreated": "교육훈련이 생성되었습니다", + "TrainingOverview": "개요", + "TrainingPassingScore": "합격 점수", + "TrainingPrefix": "접두사", + "TrainingQuestions": "질문", + "TrainingRelease": "릴리스", + "TrainingReleasedBy": "릴리스 담당자", + "TrainingReleasedOn": "릴리스 날짜", + "TrainingRequest": "교육훈련 요청", + "TrainingRequestAssign": "요청 할당", + "TrainingRequestAttempt": "시도 {index}/{total}", + "TrainingRequestCancel": "요청 취소", + "TrainingRequestCanceledBy": "취소자", + "TrainingRequestCanceledDate": "취소일", + "TrainingRequestCompletion": "완료", + "TrainingRequestDueDate": "마감일", + "TrainingRequestMaxAttempts": "최대 시도 횟수", + "TrainingRequestNoDueDate": "마감일 없음", + "TrainingRequestResults": "결과", + "TrainingRequestRoles": "역할", + "TrainingRequestRolesCount": "{count, plural, one {역할 #개} other {역할 #개}}", + "TrainingRequests": "요청", + "TrainingRequestTrainee": "수강생", + "TrainingRequestTrainees": "수강생", + "TrainingRetake": "재시도", + "TrainingSeqNumber": "번호", + "TrainingStart": "교육훈련 시작", + "TrainingStateArchived": "보관됨", + "TrainingStateDeleted": "삭제됨", + "TrainingStateDraft": "초안", + "TrainingStateReleased": "릴리스됨", + "TrainingTake": "교육훈련 수강", + "AllowToTakeTraining": "교육훈련 수강 허용", + "TrainingTitle": "제목", + "Trainings": "교육훈련", + "ViewAllTrainings": "모든 교육훈련", + "ViewAllTrainingsAll": "전체", + "ViewAllTrainingsArchived": "보관됨", + "ViewAllTrainingsDrafts": "초안", + "ViewAllTrainingsReleased": "릴리스됨", + "ViewIncomingRequests": "내 요청", + "ViewMyResults": "내 결과", + "ViewMyTrainings": "내 교육훈련", + "ViewMyTrainingsAll": "전체", + "ViewMyTrainingsArchived": "보관됨", + "ViewMyTrainingsDrafts": "초안", + "ViewMyTrainingsReleased": "릴리스됨", + "ViewSentRequests": "수강생 요청", + "ViewTraineesResults": "수강생 결과", + "ConfigLabel": "교육훈련", + "ConfigDescription": "교육훈련을 관리하기 위한 확장 기능", + "TrainingDeadlineReminder": "교육 마감 알림", + "TrainingReminderOffsetsDays": "알림 사전 기간(일)", + "Permission_SendTrainingRequestReminder": "교육 알림 보내기", + "Permission_SendTrainingRequestReminder_Description": "교육 요청을 완료하지 않은 학습자에게 후속 알림 보내기", + "SendReminder": "알림 보내기", + "ConfirmSendReminder": "이 교육을 완료하지 않은 학습자 {count}명에게 알림을 보내시겠습니까?", + "ReminderSent": "학습자 {count}명에게 알림을 보냈습니다" + } } diff --git a/plugins/training-assets/lang/pl.json b/plugins/training-assets/lang/pl.json index 1b717b1faa..c5e00ba652 100644 --- a/plugins/training-assets/lang/pl.json +++ b/plugins/training-assets/lang/pl.json @@ -99,6 +99,13 @@ "ViewSentRequests": "Wnioski szkolonych", "ViewTraineesResults": "Wyniki szkolonych", "ConfigLabel": "Szkolenia", - "ConfigDescription": "Rozszerzenie do zarządzania szkoleniami" + "ConfigDescription": "Rozszerzenie do zarządzania szkoleniami", + "TrainingDeadlineReminder": "Przypomnienie o terminie szkolenia", + "TrainingReminderOffsetsDays": "Wyprzedzenia przypomnień (dni)", + "Permission_SendTrainingRequestReminder": "Wyślij przypomnienie o szkoleniu", + "Permission_SendTrainingRequestReminder_Description": "Wysyłaj przypomnienia do uczestników, którzy nie ukończyli żądania szkolenia", + "SendReminder": "Wyślij przypomnienie", + "ConfirmSendReminder": "Wysłać przypomnienie do {count} uczestników, którzy nie ukończyli tego szkolenia?", + "ReminderSent": "Przypomnienie wysłane do {count} uczestników" } } diff --git a/plugins/training-assets/lang/pt-br.json b/plugins/training-assets/lang/pt-br.json index 776fe19f01..f53ce66e63 100644 --- a/plugins/training-assets/lang/pt-br.json +++ b/plugins/training-assets/lang/pt-br.json @@ -99,6 +99,13 @@ "ViewSentRequests": "Solicitações dos treinandos", "ViewTraineesResults": "Resultados dos treinandos", "ConfigLabel": "Treinamentos", - "ConfigDescription": "Extensão para gerenciar treinamentos" + "ConfigDescription": "Extensão para gerenciar treinamentos", + "TrainingDeadlineReminder": "Lembrete de prazo de treinamento", + "TrainingReminderOffsetsDays": "Antecedências de lembrete (dias)", + "Permission_SendTrainingRequestReminder": "Enviar lembrete de treinamento", + "Permission_SendTrainingRequestReminder_Description": "Enviar lembretes de acompanhamento aos participantes que não concluíram uma solicitação de treinamento", + "SendReminder": "Enviar lembrete", + "ConfirmSendReminder": "Enviar um lembrete a {count} participante(s) que não concluíram este treinamento?", + "ReminderSent": "Lembrete enviado a {count} participante(s)" } } diff --git a/plugins/training-assets/lang/pt.json b/plugins/training-assets/lang/pt.json index 776fe19f01..6aab6f640c 100644 --- a/plugins/training-assets/lang/pt.json +++ b/plugins/training-assets/lang/pt.json @@ -99,6 +99,13 @@ "ViewSentRequests": "Solicitações dos treinandos", "ViewTraineesResults": "Resultados dos treinandos", "ConfigLabel": "Treinamentos", - "ConfigDescription": "Extensão para gerenciar treinamentos" + "ConfigDescription": "Extensão para gerenciar treinamentos", + "TrainingDeadlineReminder": "Lembrete de prazo de formação", + "TrainingReminderOffsetsDays": "Antecedências de lembrete (dias)", + "Permission_SendTrainingRequestReminder": "Enviar lembrete de formação", + "Permission_SendTrainingRequestReminder_Description": "Enviar lembretes de acompanhamento aos formandos que não concluíram um pedido de formação", + "SendReminder": "Enviar lembrete", + "ConfirmSendReminder": "Enviar um lembrete a {count} formando(s) que não concluíram esta formação?", + "ReminderSent": "Lembrete enviado a {count} formando(s)" } } diff --git a/plugins/training-assets/lang/ru.json b/plugins/training-assets/lang/ru.json index 049bca276d..5aca6438b0 100644 --- a/plugins/training-assets/lang/ru.json +++ b/plugins/training-assets/lang/ru.json @@ -99,6 +99,14 @@ "ViewSentRequests": "Запросы респондентам", "ViewTraineesResults": "Результаты респондентов", "ConfigLabel": "Тренинги", - "ConfigDescription": "Расширение для управления тренингами" + "ConfigDescription": "Расширение для управления тренингами", + "Permission_SendTrainingRequestReminder": "Отправлять напоминания о тренинге", + "Permission_SendTrainingRequestReminder_Description": "Отправлять повторные напоминания обучаемым, не завершившим запрос на обучение", + "TrainingDeadlineReminder": "Напоминание о дедлайне тренинга", + "TrainingDeadlineReminderTitle": "Скоро дедлайн по тренингу", + "TrainingReminderOffsetsDays": "Напоминания за (дней)", + "SendReminder": "Отправить напоминание", + "ConfirmSendReminder": "Отправить напоминание {count} обучаемым, не завершившим это обучение?", + "ReminderSent": "Напоминание отправлено {count} обучаемым" } } diff --git a/plugins/training-assets/lang/tr.json b/plugins/training-assets/lang/tr.json index 2244c50563..36f9346be2 100644 --- a/plugins/training-assets/lang/tr.json +++ b/plugins/training-assets/lang/tr.json @@ -99,6 +99,13 @@ "ViewSentRequests": "Eğitim alanların talepleri", "ViewTraineesResults": "Eğitim alanların sonuçları", "ConfigLabel": "Eğitimler", - "ConfigDescription": "Eğitimleri yönetmek için uzantı" + "ConfigDescription": "Eğitimleri yönetmek için uzantı", + "TrainingDeadlineReminder": "Eğitim son tarih hatırlatması", + "TrainingReminderOffsetsDays": "Hatırlatma ön süreleri (gün)", + "Permission_SendTrainingRequestReminder": "Eğitim hatırlatması gönder", + "Permission_SendTrainingRequestReminder_Description": "Bir eğitim talebini tamamlamayan katılımcılara takip hatırlatmaları gönder", + "SendReminder": "Hatırlatma gönder", + "ConfirmSendReminder": "Bu eğitimi tamamlamayan {count} katılımcıya hatırlatma gönderilsin mi?", + "ReminderSent": "{count} katılımcıya hatırlatma gönderildi" } } diff --git a/plugins/training-assets/lang/zh.json b/plugins/training-assets/lang/zh.json index 312e70f1ca..8efd2d2839 100644 --- a/plugins/training-assets/lang/zh.json +++ b/plugins/training-assets/lang/zh.json @@ -99,6 +99,13 @@ "ViewSentRequests": "学员的请求", "ViewTraineesResults": "学员的结果", "ConfigLabel": "培训", - "ConfigDescription": "用于管理培训的扩展" + "ConfigDescription": "用于管理培训的扩展", + "TrainingDeadlineReminder": "培训截止提醒", + "TrainingReminderOffsetsDays": "提醒提前时间(天)", + "Permission_SendTrainingRequestReminder": "发送培训提醒", + "Permission_SendTrainingRequestReminder_Description": "向未完成培训请求的学员发送跟进提醒", + "SendReminder": "发送提醒", + "ConfirmSendReminder": "向未完成此培训的 {count} 名学员发送提醒?", + "ReminderSent": "已向 {count} 名学员发送提醒" } } diff --git a/plugins/training-resources/src/actions/trainingRequestSendReminderAction.ts b/plugins/training-resources/src/actions/trainingRequestSendReminderAction.ts new file mode 100644 index 0000000000..54fd188ba2 --- /dev/null +++ b/plugins/training-resources/src/actions/trainingRequestSendReminderAction.ts @@ -0,0 +1,77 @@ +// +// Copyright © 2026 TraceX SAS. +// +// Licensed under the PolyForm Shield License 1.0.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://polyformproject.org/licenses/shield/1.0.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import contact, { type Employee } from '@hcengineering/contact' +import { type AccountUuid, type Ref } from '@hcengineering/core' +import notification from '@hcengineering/notification' +import { translate } from '@hcengineering/platform' +import { getClient, MessageBox } from '@hcengineering/presentation' +import { focusActionWithAvailability } from '@hcengineering/questions-resources' +import { getCurrentLanguage } from '@hcengineering/theme' +import { type TrainingRequest, TrainingAttemptState } from '@hcengineering/training' +import { addNotification, showPopup } from '@hcengineering/ui' +import ReminderSentNotification from '../components/ReminderSentNotification.svelte' +import { canSendTrainingReminder } from '../utils/canSendTrainingReminder' +import training from '../plugin' + +/** + * Manual follow-up: after confirmation, notify every trainee of a request who has not completed it + * (no `Passed` attempt). Delivery goes through the generic `notification.class.OnDemandNotification` + * mechanism, so this action carries no bespoke notification-sending logic. + */ +export const trainingRequestSendReminderAction = focusActionWithAvailability( + async (object: TrainingRequest) => { + return canSendTrainingReminder(object) + }, + async (object: TrainingRequest) => { + const client = getClient() + + const passedAttempts = await client.findAll(training.class.TrainingAttempt, { + attachedTo: object._id, + state: TrainingAttemptState.Passed + }) + const passed = new Set>(passedAttempts.map((a) => a.owner)) + const nonCompleters = object.trainees.filter((t) => !passed.has(t)) + if (nonCompleters.length === 0) return + + const employees = await client.findAll(contact.mixin.Employee, { _id: { $in: nonCompleters } }) + const targets = employees.map((e) => e.personUuid).filter((uuid): uuid is AccountUuid => uuid != null) + if (targets.length === 0) return + + showPopup(MessageBox, { + label: training.string.SendReminder, + message: training.string.ConfirmSendReminder, + params: { count: targets.length }, + action: async () => { + await client.createDoc(notification.class.OnDemandNotification, object.space, { + targets, + objectId: object._id, + objectClass: object._class, + objectSpace: object.space, + notificationType: training.ids.TrainingDeadlineReminder, + header: training.string.TrainingDeadlineReminder, + message: training.string.TrainingDeadlineReminder, + icon: training.icon.TrainingRequest + }) + + addNotification( + await translate(training.string.ReminderSent, { count: targets.length }, getCurrentLanguage()), + '', + ReminderSentNotification + ) + } + }) + } +) diff --git a/plugins/training-resources/src/components/ReminderSentNotification.svelte b/plugins/training-resources/src/components/ReminderSentNotification.svelte new file mode 100644 index 0000000000..2afd1bca95 --- /dev/null +++ b/plugins/training-resources/src/components/ReminderSentNotification.svelte @@ -0,0 +1,25 @@ + + + + + diff --git a/plugins/training-resources/src/components/Settings.svelte b/plugins/training-resources/src/components/Settings.svelte index 8c46007132..f5fa132bce 100644 --- a/plugins/training-resources/src/components/Settings.svelte +++ b/plugins/training-resources/src/components/Settings.svelte @@ -28,10 +28,12 @@ type WithLookup } from '@hcengineering/core' import { createQuery, getClient } from '@hcengineering/presentation' - import { Breadcrumb, Header, Loading } from '@hcengineering/ui' + import { Breadcrumb, EditBox, Header, Label, Loading } from '@hcengineering/ui' import training from '../plugin' + const DEFAULT_OFFSETS_DAYS = [30, 7, 1] + const client = getClient() const hierarchy = client.getHierarchy() const query = createQuery() @@ -39,6 +41,7 @@ let spaceType: SpaceType | null = null let roles: Role[] | null = null let rolesAssignment: RolesAssignment | null = null + let reminderOffsetsInput: string = '' $: { query.query( core.class.TypedSpace, @@ -62,6 +65,13 @@ {} ) } + + if (space !== null) { + const offsets = hierarchy.hasMixin(space, training.mixin.TrainingReminderSettings) + ? (hierarchy.as(space, training.mixin.TrainingReminderSettings).reminderOffsetsDays ?? DEFAULT_OFFSETS_DAYS) + : DEFAULT_OFFSETS_DAYS + reminderOffsetsInput = offsets.join(', ') + } }, { lookup: { @@ -80,6 +90,24 @@ rolesAssignment[roleId] = members await client.updateMixin(space._id, space._class, space.space, spaceType.targetClass, rolesAssignment) } + + // Parse a comma/space separated list of positive day offsets, de-duplicated and sorted descending. + function parseOffsets (input: string): number[] { + const parsed = input + .split(/[\s,]+/) + .map((s) => Number.parseInt(s, 10)) + .filter((n) => Number.isFinite(n) && n > 0) + return Array.from(new Set(parsed)).sort((a, b) => b - a) + } + + async function onOffsetsChange (): Promise { + if (space === null) return + const reminderOffsetsDays = parseOffsets(reminderOffsetsInput) + reminderOffsetsInput = reminderOffsetsDays.join(', ') + await client.updateMixin(space._id, space._class, space.space, training.mixin.TrainingReminderSettings, { + reminderOffsetsDays + }) + }
@@ -106,6 +134,18 @@ allowGuests /> {/each} + +
+
+ { + void onOffsetsChange() + }} + />
{/if} diff --git a/plugins/training-resources/src/index.ts b/plugins/training-resources/src/index.ts index 5532886810..44025eca77 100644 --- a/plugins/training-resources/src/index.ts +++ b/plugins/training-resources/src/index.ts @@ -9,6 +9,7 @@ import { trainingDeleteAction } from './actions/trainingDeleteAction' import { trainingDuplicateAction } from './actions/trainingDuplicateAction' import { trainingReleaseAction } from './actions/trainingReleaseAction' import { trainingRequestCancelAction } from './actions/trainingRequestCancelAction' +import { trainingRequestSendReminderAction } from './actions/trainingRequestSendReminderAction' import { trainingRequestChangeOwnerAction } from './actions/trainingRequestChangeOwnerAction' import { trainingRequestCreateAction } from './actions/trainingRequestCreateAction' import EmployeeEditor from './components/EmployeeEditor.svelte' @@ -114,6 +115,8 @@ export default async (): Promise => ({ TrainingRequestCancelAction: trainingRequestCancelAction.action, TrainingRequestCancelIsAvailable: trainingRequestCancelAction.isAvailable, + TrainingRequestSendReminderAction: trainingRequestSendReminderAction.action, + TrainingRequestSendReminderIsAvailable: trainingRequestSendReminderAction.isAvailable, TrainingRequestChangeOwnerAction: trainingRequestChangeOwnerAction.action, TrainingRequestChangeOwnerIsAvailable: trainingRequestChangeOwnerAction.isAvailable, diff --git a/plugins/training-resources/src/plugin.ts b/plugins/training-resources/src/plugin.ts index 5286717704..eadacec59a 100644 --- a/plugins/training-resources/src/plugin.ts +++ b/plugins/training-resources/src/plugin.ts @@ -110,6 +110,10 @@ export default mergeIds(trainingId, training, { TrainingRequestCancelAction: '' as Resource>, TrainingRequestCancelIsAvailable: '' as Resource>, + TrainingRequestSendReminder: '' as Ref>, + TrainingRequestSendReminderAction: '' as Resource>, + TrainingRequestSendReminderIsAvailable: '' as Resource>, + TrainingRequestChangeOwner: '' as Ref>, TrainingRequestChangeOwnerAction: '' as Resource>, TrainingRequestChangeOwnerIsAvailable: '' as Resource>, diff --git a/plugins/training-resources/src/utils/canSendTrainingReminder.ts b/plugins/training-resources/src/utils/canSendTrainingReminder.ts new file mode 100644 index 0000000000..a69ac4ac2f --- /dev/null +++ b/plugins/training-resources/src/utils/canSendTrainingReminder.ts @@ -0,0 +1,28 @@ +// +// Copyright © 2026 TraceX SAS. +// +// Licensed under the PolyForm Shield License 1.0.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://polyformproject.org/licenses/shield/1.0.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { checkMyPermission, permissionsStore } from '@hcengineering/contact-resources' +import type { TrainingRequest } from '@hcengineering/training' +import { get } from 'svelte/store' +import { getCurrentEmployeeRef } from './getCurrentEmployeeRef' +import training from '../plugin' + +export function canSendTrainingReminder (object: TrainingRequest): boolean { + return ( + object.canceledOn === null && + (object.owner === getCurrentEmployeeRef() || + checkMyPermission(training.permission.SendTrainingRequestReminder, object.space, get(permissionsStore))) + ) +} diff --git a/plugins/training/package.json b/plugins/training/package.json index cf68b7caa4..e857a803ce 100644 --- a/plugins/training/package.json +++ b/plugins/training/package.json @@ -37,8 +37,10 @@ }, "dependencies": { "@hcengineering/attachment": "workspace:^0.7.0", + "@hcengineering/calendar": "workspace:^0.7.0", "@hcengineering/contact": "workspace:^0.7.0", "@hcengineering/core": "workspace:^0.7.26", + "@hcengineering/notification": "workspace:^0.7.0", "@hcengineering/platform": "workspace:^0.7.20", "@hcengineering/ui": "workspace:^0.7.0", "@hcengineering/questions": "workspace:^0.7.0" diff --git a/plugins/training/src/index.ts b/plugins/training/src/index.ts index ad4785a2b5..a3cf3855e4 100644 --- a/plugins/training/src/index.ts +++ b/plugins/training/src/index.ts @@ -20,9 +20,12 @@ import { type TrainingRequest, type TrainingAttempt, type TrainingAttemptState, + type TrainingDeadlineEvent, + type TrainingReminderSettings, TrainingState } from './types' import type { AnyComponent } from '@hcengineering/ui/src/types' +import { type NotificationType } from '@hcengineering/notification' /** @public */ export const trainingId = 'training' as Plugin @@ -37,6 +40,7 @@ export default plugin(trainingId, { Canceled: '' as IntlString, ChangeOwner: '' as IntlString, Completed: '' as IntlString, + ConfirmSendReminder: '' as IntlString, CreateNewVersion: '' as IntlString, Duplicate: '' as IntlString, IncomingRequestStateCanceled: '' as IntlString, @@ -54,6 +58,8 @@ export default plugin(trainingId, { Permission_CreateRequestOnSomeoneElsesTraining_Description: '' as IntlString, Permission_CreateTraining: '' as IntlString, Permission_CreateTraining_Description: '' as IntlString, + Permission_SendTrainingRequestReminder: '' as IntlString, + Permission_SendTrainingRequestReminder_Description: '' as IntlString, Permission_ViewSomeoneElsesSentRequest: '' as IntlString, Permission_ViewSomeoneElsesSentRequest_Description: '' as IntlString, Permission_ViewSomeoneElsesTraineesResults: '' as IntlString, @@ -62,7 +68,9 @@ export default plugin(trainingId, { Permission_ViewSomeoneElsesTrainingOverview_Description: '' as IntlString, Permission_ViewSomeoneElsesTrainingQuestions: '' as IntlString, Permission_ViewSomeoneElsesTrainingQuestions_Description: '' as IntlString, + ReminderSent: '' as IntlString, Revision: '' as IntlString, + SendReminder: '' as IntlString, SentYouATrainingRequest: '' as IntlString, State: '' as IntlString, Training: '' as IntlString, @@ -82,6 +90,7 @@ export default plugin(trainingId, { TrainingAuthor: '' as IntlString, TrainingCreate: '' as IntlString, TrainingCreated: '' as IntlString, + TrainingDeadlineReminder: '' as IntlString, TrainingDocuments: '' as IntlString, TrainingOverview: '' as IntlString, TrainingPassingScore: '' as IntlString, @@ -90,6 +99,7 @@ export default plugin(trainingId, { TrainingRelease: '' as IntlString, TrainingReleasedBy: '' as IntlString, TrainingReleasedOn: '' as IntlString, + TrainingReminderOffsetsDays: '' as IntlString, TrainingRequest: '' as IntlString, TrainingRequestAssign: '' as IntlString, TrainingRequestAttempt: '' as IntlString, @@ -155,6 +165,7 @@ export default plugin(trainingId, { Training: '' as Ref>, TrainingRequest: '' as Ref>, TrainingAttempt: '' as Ref>, + TrainingDeadlineEvent: '' as Ref>, TypeTrainingAttemptState: '' as Ref>>, TypeTrainingState: '' as Ref>> }, @@ -163,13 +174,15 @@ export default plugin(trainingId, { ChangeSomeoneElsesTrainingOwner: '' as Ref>, CreateRequestOnSomeoneElsesTraining: '' as Ref>, CreateTraining: '' as Ref>, + SendTrainingRequestReminder: '' as Ref>, ViewSomeoneElsesSentRequest: '' as Ref>, ViewSomeoneElsesTraineesResults: '' as Ref>, ViewSomeoneElsesTrainingOverview: '' as Ref>, ViewSomeoneElsesTrainingQuestions: '' as Ref> }, mixin: { - TrainingsTypeData: '' as Ref> + TrainingsTypeData: '' as Ref>, + TrainingReminderSettings: '' as Ref> }, space: { Trainings: '' as Ref @@ -190,5 +203,9 @@ export default plugin(trainingId, { }, setting: { Trainings: '' as Ref + }, + ids: { + // Declared on the runtime plugin (not the model) so the events-processor worker can reference it. + TrainingDeadlineReminder: '' as Ref } }) diff --git a/plugins/training/src/types.ts b/plugins/training/src/types.ts index dc5084d307..e52528ff04 100644 --- a/plugins/training/src/types.ts +++ b/plugins/training/src/types.ts @@ -1,4 +1,5 @@ import type { Attachment } from '@hcengineering/attachment' +import type { Event } from '@hcengineering/calendar' import type { Employee } from '@hcengineering/contact' import { type AttachedDoc, @@ -69,6 +70,37 @@ export interface TrainingRequest extends AttachedDoc | null } +/** + * Per-space (per-workspace training area) configuration for deadline reminders. + * + * Mixed into the Trainings `TypedSpace`. `reminderOffsetsDays` is the series of lead times, in days, + * at which a reminder fires before a `TrainingRequest.dueDate` (e.g. `[30, 7, 1]`). The server trigger + * reads this when creating deadline events; a default is used when the mixin is absent. + * + * @public + */ +export interface TrainingReminderSettings extends TypedSpace { + reminderOffsetsDays: number[] +} + +/** + * A calendar event that materializes a training deadline on a trainee's calendar. + * + * One `TrainingDeadlineEvent` is created per trainee of a {@link TrainingRequest}, dated on the + * request's `dueDate`. Because it is a `calendar.Event` subclass, the existing `OnEvent` trigger + * schedules / reschedules / cancels its `reminders` automatically. The event is `attachedTo` the + * `TrainingRequest`, which lets the events-processor redirect the reminder notification to the + * training and suppress it once the trainee has passed. + * + * @public + */ +export interface TrainingDeadlineEvent extends Event { + /** The assignment this deadline belongs to (also mirrored in `attachedTo`). */ + request: Ref + /** The single trainee this event (and its reminders) is for. */ + trainee: Ref +} + /** @public */ export enum TrainingAttemptState { Draft = 'draft', diff --git a/server-plugins/notification-resources/src/index.ts b/server-plugins/notification-resources/src/index.ts index 4a29f17781..aafcb5a862 100644 --- a/server-plugins/notification-resources/src/index.ts +++ b/server-plugins/notification-resources/src/index.ts @@ -56,7 +56,8 @@ import notification, { DocNotifyContext, InboxNotification, MentionInboxNotification, - NotificationType + NotificationType, + type OnDemandNotification } from '@hcengineering/notification' import { getResource, translate } from '@hcengineering/platform' import { getAccountBySocialId, getEmployeesBySocialIds } from '@hcengineering/server-contact' @@ -75,6 +76,7 @@ import { NotifyResult } from './types' import { + getAllowedProviders, getHTMLPresenter, getNotificationContent, getNotificationLink, @@ -1660,12 +1662,74 @@ export * from './push' export * from './types' export * from './utils' +/** + * Generic on-demand delivery: fans an {@link OnDemandNotification} command doc out into each target's + * private space (system trust), reusing {@link getCommonNotificationTxes}. Domain-agnostic — any module + * can create the command doc to "notify these users now" about any object. + */ +export async function OnDemandNotificationSend (txes: Tx[], control: TriggerControl): Promise { + const res: Tx[] = [] + for (const tx of txes) { + if (!control.hierarchy.isDerived(tx._class, core.class.TxCreateDoc)) continue + const createTx = tx as TxCreateDoc + if (!control.hierarchy.isDerived(createTx.objectClass, notification.class.OnDemandNotification)) continue + + const req = TxProcessor.createDoc2Doc(createTx) + if (req.targets.length === 0) continue + + const type = control.modelDb.findAllSync(notification.class.NotificationType, { _id: req.notificationType })[0] + if (type === undefined) continue + + const targetDoc = (await control.findAll(control.ctx, req.objectClass, { _id: req.objectId }, { limit: 1 }))[0] + if (targetDoc === undefined) continue + + const sender: SenderInfo = await getSenderInfo(control.ctx, createTx.modifiedBy, control) + const receivers = await getReceiversInfo(control.ctx, req.targets, control) + if (receivers.length === 0) continue + const notificationControl = await getNotificationProviderControl(control.ctx, control) + + for (const receiver of receivers) { + const data: Partial> = { + header: req.header, + message: req.message, + messageHtml: req.messageHtml, + icon: req.icon, + objectId: req.objectId, + objectClass: req.objectClass, + user: receiver.account, + isViewed: false, + archived: false + } + const allowedProviders = getAllowedProviders(control, receiver.socialIds, type, notificationControl) + const notifyResult: NotifyResult = new Map(allowedProviders.map((it) => [it, [type]])) + if (notifyResult.has(notification.providers.InboxNotificationProvider)) { + const notifyTxes = await getCommonNotificationTxes( + control.ctx, + control, + targetDoc, + data, + receiver, + sender, + req.objectId, + req.objectClass, + req.objectSpace, + createTx.modifiedOn, + notifyResult + ) + res.push(...notifyTxes) + } + } + } + return res +} + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export default async () => ({ trigger: { OnAttributeCreate, OnAttributeUpdate, OnDocRemove, + OnDemandNotificationSend, OnEmployeeDeactivate, PushNotificationsHandler }, diff --git a/server-plugins/notification/src/index.ts b/server-plugins/notification/src/index.ts index 291a368bd3..77f28be76e 100644 --- a/server-plugins/notification/src/index.ts +++ b/server-plugins/notification/src/index.ts @@ -121,6 +121,7 @@ export default plugin(serverNotificationId, { OnAttributeUpdate: '' as Resource, OnReactionChanged: '' as Resource, OnDocRemove: '' as Resource, + OnDemandNotificationSend: '' as Resource, OnEmployeeDeactivate: '' as Resource, PushNotificationsHandler: '' as Resource }, diff --git a/server-plugins/training-resources/package.json b/server-plugins/training-resources/package.json index 689d097046..c2b34ba126 100644 --- a/server-plugins/training-resources/package.json +++ b/server-plugins/training-resources/package.json @@ -37,10 +37,12 @@ "typescript": "^5.9.3" }, "dependencies": { + "@hcengineering/calendar": "workspace:^0.7.0", "@hcengineering/contact": "workspace:^0.7.0", "@hcengineering/core": "workspace:^0.7.26", "@hcengineering/notification": "workspace:^0.7.0", "@hcengineering/platform": "workspace:^0.7.20", + "@hcengineering/server-contact": "workspace:^0.7.0", "@hcengineering/server-core": "workspace:^0.7.19", "@hcengineering/server-notification": "workspace:^0.7.0", "@hcengineering/workbench": "workspace:^0.7.0", diff --git a/server-plugins/training-resources/src/index.ts b/server-plugins/training-resources/src/index.ts index 3107156d63..bea9e63cb9 100644 --- a/server-plugins/training-resources/src/index.ts +++ b/server-plugins/training-resources/src/index.ts @@ -16,6 +16,7 @@ import { TrainingRequestHTMLPresenter } from './functions/TrainingRequestHTMLPresenter' import { TrainingRequestNotificationTypeMatch } from './functions/TrainingRequestNotificationTypeMatch' import { TrainingRequestTextPresenter } from './functions/TrainingRequestTextPresenter' +import { OnTrainingAttempt, OnTrainingReminderSettings, OnTrainingRequest } from './triggers' // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export default async () => ({ @@ -23,5 +24,10 @@ export default async () => ({ TrainingRequestHTMLPresenter, TrainingRequestNotificationTypeMatch, TrainingRequestTextPresenter + }, + trigger: { + OnTrainingRequest, + OnTrainingAttempt, + OnTrainingReminderSettings } }) diff --git a/server-plugins/training-resources/src/triggers.ts b/server-plugins/training-resources/src/triggers.ts new file mode 100644 index 0000000000..39cbc49830 --- /dev/null +++ b/server-plugins/training-resources/src/triggers.ts @@ -0,0 +1,248 @@ +// +// Copyright © 2026 TraceX SAS. +// +// Licensed under the PolyForm Shield License 1.0.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://polyformproject.org/licenses/shield/1.0.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import calendar, { AccessLevel, generateEventId, getPrimaryCalendar } from '@hcengineering/calendar' +import { type Employee, type Person } from '@hcengineering/contact' +import core, { + type Data, + type DocumentUpdate, + pickPrimarySocialId, + type Ref, + type Space, + type Tx, + type TxCreateDoc, + type TxMixin, + type TxRemoveDoc, + type TxUpdateDoc, + type TypedSpace +} from '@hcengineering/core' +import { getAccountBySocialId, getSocialIds } from '@hcengineering/server-contact' +import { type TriggerControl } from '@hcengineering/server-core' +import training, { + type Training, + TrainingAttemptState, + type TrainingDeadlineEvent, + type TrainingReminderSettings, + type TrainingRequest +} from '@hcengineering/training' + +const DAY_MS = 24 * 60 * 60 * 1000 +const DEFAULT_OFFSETS_DAYS = [30, 7, 1] + +function sameOffsets (a: number[] | undefined, b: number[]): boolean { + if (a === undefined || a.length !== b.length) return false + const sa = [...a].sort((x, y) => x - y) + const sb = [...b].sort((x, y) => x - y) + return sa.every((v, i) => v === sb[i]) +} + +/** + * Resolve the reminder lead-time series (in ms before `dueDate`) configured for a Trainings space. + * Reads the {@link training.mixin.TrainingReminderSettings} mixin on the space; falls back to a default. + */ +async function resolveReminderOffsetsMs (control: TriggerControl, spaceRef: Ref): Promise { + let days = DEFAULT_OFFSETS_DAYS + const space = (await control.findAll(control.ctx, core.class.TypedSpace, { _id: spaceRef }, { limit: 1 }))[0] + if (space !== undefined && control.hierarchy.hasMixin(space, training.mixin.TrainingReminderSettings)) { + const settings = control.hierarchy.as(space, training.mixin.TrainingReminderSettings) + if (Array.isArray(settings.reminderOffsetsDays) && settings.reminderOffsetsDays.length > 0) { + days = settings.reminderOffsetsDays + } + } + return days.filter((d) => typeof d === 'number' && d > 0).map((d) => d * DAY_MS) +} + +/** + * Build a `TxCreateDoc` for a per-trainee {@link TrainingDeadlineEvent}, resolving the trainee's + * primary calendar + social id the same way the calendar `OnEvent` trigger does for participants. + * + * The event is created with `access = Reader` so calendar's own participant fan-out is skipped, while + * its `reminders` are still scheduled by the `OnEvent` trigger (scheduling runs regardless of access). + */ +async function buildDeadlineEventTx ( + control: TriggerControl, + request: TrainingRequest, + trainee: Ref, + offsetsMs: number[], + title: string +): Promise { + if (request.dueDate == null) return undefined + + const socialIds = await getSocialIds(control, trainee as unknown as Ref) + if (socialIds.length === 0) return undefined + const primary = pickPrimarySocialId(socialIds)._id + const acc = await getAccountBySocialId(control, primary) + if (acc == null) return undefined + + const calendars = await control.findAll(control.ctx, calendar.class.Calendar, { user: primary, hidden: false }) + const cal = getPrimaryCalendar(calendars, undefined, acc) + + const data: Data = { + attachedTo: request._id, + attachedToClass: training.class.TrainingRequest, + collection: 'trainingDeadlineEvents', + calendar: cal, + eventId: generateEventId(), + title, + description: '', + allDay: true, + date: request.dueDate, + dueDate: request.dueDate, + participants: [trainee as unknown as Ref], + reminders: offsetsMs, + access: AccessLevel.Reader, + user: primary, + blockTime: false, + request: request._id, + trainee + } + + return control.txFactory.createTxCreateDoc(training.class.TrainingDeadlineEvent, calendar.space.Calendar, data) +} + +/** + * Idempotently reconcile the set of deadline events for a request against its current trainees / dueDate. + * Handles create, dueDate change, trainee add/remove and cancellation in one path. + */ +async function reconcileDeadlineEvents (control: TriggerControl, requestId: Ref): Promise { + const request = ( + await control.findAll(control.ctx, training.class.TrainingRequest, { _id: requestId }, { limit: 1 }) + )[0] + const existing = await control.findAll(control.ctx, training.class.TrainingDeadlineEvent, { request: requestId }) + + // Request gone, cancelled, or without a deadline → drop all deadline events. + if (request?.dueDate == null || request.canceledOn != null) { + return existing.map((ev) => control.txFactory.createTxRemoveDoc(ev._class, ev.space, ev._id)) + } + + const res: Tx[] = [] + const offsetsMs = await resolveReminderOffsetsMs(control, request.space) + const training0 = ( + await control.findAll(control.ctx, training.class.Training, { _id: request.attachedTo }, { limit: 1 }) + )[0] as Training | undefined + const title = training0?.title ?? 'Training' + + const wanted = new Set(request.trainees) + const byTrainee = new Map(existing.map((ev) => [ev.trainee, ev])) + + // Create for newly-added trainees; keep dueDate + reminder offsets in sync for existing ones. + for (const trainee of request.trainees) { + const ev = byTrainee.get(trainee) + if (ev === undefined) { + const tx = await buildDeadlineEventTx(control, request, trainee, offsetsMs, title) + if (tx !== undefined) res.push(tx) + } else { + const ops: DocumentUpdate = {} + if (ev.date !== request.dueDate) { + ops.date = request.dueDate + ops.dueDate = request.dueDate + } + if (!sameOffsets(ev.reminders, offsetsMs)) { + ops.reminders = offsetsMs + } + if (Object.keys(ops).length > 0) { + res.push(control.txFactory.createTxUpdateDoc(ev._class, ev.space, ev._id, ops)) + } + } + } + + // Remove events for trainees no longer on the request. + for (const ev of existing) { + if (!wanted.has(ev.trainee)) { + res.push(control.txFactory.createTxRemoveDoc(ev._class, ev.space, ev._id)) + } + } + + return res +} + +/** + * Trigger on `TrainingRequest` changes: (re)build the trainees' deadline events. + * @public + */ +export async function OnTrainingRequest (txes: Tx[], control: TriggerControl): Promise { + const res: Tx[] = [] + for (const tx of txes) { + if (!control.hierarchy.isDerived(tx._class, core.class.TxCUD)) continue + const cud = tx as TxCreateDoc | TxUpdateDoc | TxRemoveDoc + if (!control.hierarchy.isDerived(cud.objectClass, training.class.TrainingRequest)) continue + res.push(...(await reconcileDeadlineEvents(control, cud.objectId))) + } + return res +} + +/** + * Trigger on `TrainingReminderSettings` mixin changes: when the reminder offsets for a Trainings space + * change, reconcile every open request in that space so existing deadline events pick up the new series. + * @public + */ +export async function OnTrainingReminderSettings (txes: Tx[], control: TriggerControl): Promise { + const spaces = new Set>() + for (const tx of txes) { + if (!control.hierarchy.isDerived(tx._class, core.class.TxMixin)) continue + const mixinTx = tx as TxMixin + if (mixinTx.mixin !== training.mixin.TrainingReminderSettings) continue + spaces.add(mixinTx.objectId as Ref) + } + + const res: Tx[] = [] + for (const space of spaces) { + const requests = await control.findAll(control.ctx, training.class.TrainingRequest, { + space, + canceledOn: null + }) + for (const request of requests) { + res.push(...(await reconcileDeadlineEvents(control, request._id))) + } + } + return res +} + +/** + * Trigger on `TrainingAttempt` changes: when a trainee passes, remove their deadline event + * (reminders are cancelled by calendar's `onRemoveEvent`). A reminder already in flight is also + * suppressed by the events-processor once the attempt is `Passed`. + * @public + */ +export async function OnTrainingAttempt (txes: Tx[], control: TriggerControl): Promise { + const res: Tx[] = [] + for (const tx of txes) { + if (!control.hierarchy.isDerived(tx._class, core.class.TxCUD)) continue + const cud = tx as TxCreateDoc | TxUpdateDoc + if (!control.hierarchy.isDerived(cud.objectClass, training.class.TrainingAttempt)) continue + + let becamePassed = false + if (control.hierarchy.isDerived(cud._class, core.class.TxCreateDoc)) { + becamePassed = (cud as TxCreateDoc).attributes?.state === TrainingAttemptState.Passed + } else if (control.hierarchy.isDerived(cud._class, core.class.TxUpdateDoc)) { + becamePassed = (cud as TxUpdateDoc).operations?.state === TrainingAttemptState.Passed + } + if (!becamePassed) continue + + const attempt = ( + await control.findAll(control.ctx, training.class.TrainingAttempt, { _id: cud.objectId }, { limit: 1 }) + )[0] + if (attempt === undefined) continue + + const events = await control.findAll(control.ctx, training.class.TrainingDeadlineEvent, { + request: attempt.attachedTo, + trainee: attempt.owner + }) + for (const ev of events) { + res.push(control.txFactory.createTxRemoveDoc(ev._class, ev.space, ev._id)) + } + } + return res +} diff --git a/server-plugins/training/src/index.ts b/server-plugins/training/src/index.ts index 8dd35f3600..c5e2bcdd7e 100644 --- a/server-plugins/training/src/index.ts +++ b/server-plugins/training/src/index.ts @@ -15,6 +15,7 @@ import { plugin, type Plugin, type Resource } from '@hcengineering/platform' import { type Presenter, TypeMatchFunc } from '@hcengineering/server-notification' +import { type TriggerFunc } from '@hcengineering/server-core' import type { TrainingRequest } from '@hcengineering/training' /** @@ -30,5 +31,10 @@ export default plugin(serverTrainingId, { TrainingRequestHTMLPresenter: '' as Resource>, TrainingRequestNotificationTypeMatch: '' as TypeMatchFunc, TrainingRequestTextPresenter: '' as Resource> + }, + trigger: { + OnTrainingRequest: '' as Resource, + OnTrainingAttempt: '' as Resource, + OnTrainingReminderSettings: '' as Resource } }) diff --git a/services/notification/pod-events-processor/src/__tests__/worker.test.ts b/services/notification/pod-events-processor/src/__tests__/worker.test.ts index 79a32c7fd6..f253c302c2 100644 --- a/services/notification/pod-events-processor/src/__tests__/worker.test.ts +++ b/services/notification/pod-events-processor/src/__tests__/worker.test.ts @@ -54,7 +54,10 @@ describe('handleScheduledNotification', () => { } return false }) - const getHierarchy = jest.fn(() => ({ isDerived })) + // By default no event class carries a ReminderNotificationPresenter mixin; individual tests can + // override this to exercise the declarative-presenter branch. + const classHierarchyMixin = jest.fn(() => undefined) + const getHierarchy = jest.fn(() => ({ isDerived, classHierarchyMixin })) const client = { findOne, createDoc, getHierarchy } const accountClient = { findPersonBySocialId } @@ -407,6 +410,96 @@ describe('handleScheduledNotification', () => { }) }) + // -------------------------------------------------------------------------------- + // Declarative ReminderNotificationPresenter branch (e.g. training deadline events). + // -------------------------------------------------------------------------------- + describe('ReminderNotificationPresenter branch', () => { + const expectedNotificationId = buildReminderNotificationId(eventMessage.id) + const presenter = { + redirectToAttached: true, + notificationType: 'training:ids:TrainingDeadlineReminder', + header: 'training:string:TrainingDeadlineReminder', + message: 'training:string:TrainingDeadlineReminder', + headerIcon: 'training:icon:TrainingRequest' + } + + it('redirects the notification to the attached parent and applies the mixin content', async () => { + classHierarchyMixin.mockReturnValueOnce(presenter as any) + findPersonBySocialId.mockResolvedValue('trainee-uuid-1') + findOne.mockImplementation(async (klass: any, query: any) => { + if (klass === eventClassRef) { + return { + _id: eventMessage.eventId, + _class: eventClassRef, + space: 'event-space-1', + attachedTo: 'request-1', + attachedToClass: 'training:class:TrainingRequest', + user: 'social:1', + title: 'Safety training', + date: Date.now() + } + } + if (klass === 'training:class:TrainingRequest' && query?._id === 'request-1') { + return { _id: 'request-1', _class: 'training:class:TrainingRequest', space: 'training-space-1' } + } + if (klass === contact.class.Person && query?.personUuid === 'trainee-uuid-1') { + return { _id: 'trainee-1-doc' } + } + if (klass === contact.class.PersonSpace) return { _id: 'trainee-space-1' } + return undefined + }) + createDoc.mockResolvedValueOnce('doc-notify-created-id').mockResolvedValueOnce(expectedNotificationId) + + await handleScheduledNotification(ctx, workspaceUuid, eventMessage, control) + + // DocNotifyContext points at the redirected parent, not the event. + expect(createDoc).toHaveBeenNthCalledWith( + 1, + notification.class.DocNotifyContext, + 'trainee-space-1', + expect.objectContaining({ + objectId: 'request-1', + objectClass: 'training:class:TrainingRequest', + objectSpace: 'training-space-1', + user: 'trainee-uuid-1' + }), + undefined, + undefined, + core.account.System + ) + + // Notification points at the parent and uses the mixin's content + notification type. + expect(createDoc).toHaveBeenNthCalledWith( + 2, + notification.class.CommonInboxNotification, + 'trainee-space-1', + expect.objectContaining({ + objectId: 'request-1', + objectClass: 'training:class:TrainingRequest', + header: presenter.header, + message: presenter.message, + headerIcon: presenter.headerIcon, + types: [presenter.notificationType] + }), + expectedNotificationId, + undefined, + core.account.System + ) + }) + + it('skips (no notification) when the event is already gone — covers suppression on completion', async () => { + // When a trainee passes, the deadline event is removed; the pod finds no event and returns. + findOne.mockImplementation(async (klass: any) => { + if (klass === eventClassRef) return undefined + return undefined + }) + + await handleScheduledNotification(ctx, workspaceUuid, eventMessage, control) + + expect(createDoc).not.toHaveBeenCalled() + }) + }) + // -------------------------------------------------------------------------------- // Idempotency + error logging are independent of which branch we took. // -------------------------------------------------------------------------------- diff --git a/services/notification/pod-events-processor/src/worker.ts b/services/notification/pod-events-processor/src/worker.ts index 4658b96925..89d26bce26 100644 --- a/services/notification/pod-events-processor/src/worker.ts +++ b/services/notification/pod-events-processor/src/worker.ts @@ -26,8 +26,13 @@ import core, { type Space, type WorkspaceUuid } from '@hcengineering/core' +import { type Asset, type IntlString } from '@hcengineering/platform' import type { ConsumerControl } from '@hcengineering/server-core' -import notification, { type CommonInboxNotification, type DocNotifyContext } from '@hcengineering/notification' +import notification, { + type CommonInboxNotification, + type DocNotifyContext, + type NotificationType +} from '@hcengineering/notification' import { jsonToMarkup, nodeDoc, nodeParagraph, nodeText } from '@hcengineering/text-core' import time, { type ToDo } from '@hcengineering/time' import { getClient, type ClientBundle } from './client' @@ -51,6 +56,11 @@ interface ReminderTarget { titleText: string receiverAccount: AccountUuid receiverSpace: Ref + // Optional content overrides; default to calendar's generic reminder strings when absent. + headerIcon?: Asset + header?: IntlString + message?: IntlString + notificationTypes?: Ref[] } export async function handleScheduledNotification ( @@ -133,11 +143,11 @@ export async function handleScheduledNotification ( user: target.receiverAccount, objectId: target.objectId, objectClass: target.objectClass, - headerIcon: calendar.icon.Reminder, - header: calendar.string.Reminder, - message: calendar.string.Reminder, + headerIcon: target.headerIcon ?? calendar.icon.Reminder, + header: target.header ?? calendar.string.Reminder, + message: target.message ?? calendar.string.Reminder, messageHtml: jsonToMarkup(nodeDoc(nodeParagraph(nodeText(target.titleText)))), - types: [calendar.ids.ReminderNotification], + types: target.notificationTypes ?? [calendar.ids.ReminderNotification], isViewed: false, archived: false, docNotifyContext: docNotifyContext._id @@ -170,16 +180,43 @@ export async function handleScheduledNotification ( }) } +// Resolves the receiver of a plain calendar event from `event.user` (a PersonId / social id). +async function resolveEventUserReceiver ( + bundle: ClientBundle, + event: MinimalEvent +): Promise<{ receiverAccount: AccountUuid, receiverSpace: Ref } | undefined> { + const { client, accountClient } = bundle + const receiverSocialId = event.user + if (receiverSocialId == null) return undefined + + const personUuid = await accountClient.findPersonBySocialId(receiverSocialId, true) + if (personUuid == null) return undefined + + const person = await client.findOne(contact.class.Person, { personUuid }, { projection: { _id: 1 } }) + if (person === undefined) return undefined + + const space = await client.findOne( + contact.class.PersonSpace, + { person: person._id as Ref }, + { projection: { _id: 1 } } + ) + if (space === undefined) return undefined + + return { receiverAccount: personUuid as AccountUuid, receiverSpace: space._id } +} + // Resolves where a reminder fires: // - For Events whose `attachedToClass` is `time:class:ToDo` (or a subclass like `ProjectToDo`), // the notification points at the parent ToDo and is suppressed when the ToDo is already done. +// - For Event subclasses carrying a `ReminderNotificationPresenter` mixin, the mixin declaratively +// overrides the redirect target and content (no per-domain code in this pod). // - For any other Event the notification points at the Event itself. async function resolveReminderTarget ( bundle: ClientBundle, event: MinimalEvent, hierarchy: Hierarchy ): Promise { - const { client, accountClient } = bundle + const { client } = bundle const isToDoBacked = event.attachedToClass != null && hierarchy.isDerived(event.attachedToClass, time.class.ToDo) if (isToDoBacked && event.attachedTo != null && event.attachedToClass != null) { @@ -205,29 +242,49 @@ async function resolveReminderTarget ( } } - // Plain calendar event: receiver is identified by `event.user` (a PersonId / social id). - const receiverSocialId = event.user - if (receiverSocialId == null) return undefined + // Declarative per-class customization: any Event subclass may carry a `ReminderNotificationPresenter` + // mixin to redirect the reminder to its attached parent and/or override its content. Read straight + // from the model — the pod stays domain-agnostic (no dependency on the subclass's plugin). + const presenter = hierarchy.classHierarchyMixin(event._class, calendar.mixin.ReminderNotificationPresenter) + if (presenter !== undefined) { + const receiver = await resolveEventUserReceiver(bundle, event) + if (receiver === undefined) return undefined - const personUuid = await accountClient.findPersonBySocialId(receiverSocialId, true) - if (personUuid == null) return undefined + let objectId: Ref = event._id + let objectClass: Ref> = event._class + let objectSpace: Ref = event.space + if (presenter.redirectToAttached === true && event.attachedTo != null && event.attachedToClass != null) { + const parent = await client.findOne(event.attachedToClass, { _id: event.attachedTo }) + if (parent === undefined) return undefined + objectId = parent._id + objectClass = parent._class + objectSpace = parent.space + } - const person = await client.findOne(contact.class.Person, { personUuid }, { projection: { _id: 1 } }) - if (person === undefined) return undefined + return { + objectId, + objectClass, + objectSpace, + titleText: event.title ?? '', + receiverAccount: receiver.receiverAccount, + receiverSpace: receiver.receiverSpace, + headerIcon: presenter.headerIcon, + header: presenter.header, + message: presenter.message, + notificationTypes: presenter.notificationType != null ? [presenter.notificationType] : undefined + } + } - const space = await client.findOne( - contact.class.PersonSpace, - { person: person._id as Ref }, - { projection: { _id: 1 } } - ) - if (space === undefined) return undefined + // Plain calendar event: the notification points at the Event itself. + const receiver = await resolveEventUserReceiver(bundle, event) + if (receiver === undefined) return undefined return { objectId: event._id, objectClass: event._class, objectSpace: event.space, titleText: event.title ?? '', - receiverAccount: personUuid as AccountUuid, - receiverSpace: space._id + receiverAccount: receiver.receiverAccount, + receiverSpace: receiver.receiverSpace } } From fd248c70c3580bdc24dda2b0fce6acd83d403c7d Mon Sep 17 00:00:00 2001 From: Artem Savchenko Date: Thu, 16 Jul 2026 14:43:55 +0700 Subject: [PATCH 2/4] Fix training settings Signed-off-by: Artem Savchenko --- models/training/src/types.ts | 4 + plugins/training-assets/lang/cs.json | 3 +- plugins/training-assets/lang/de.json | 3 +- plugins/training-assets/lang/en.json | 3 +- plugins/training-assets/lang/es.json | 3 +- plugins/training-assets/lang/fr.json | 3 +- plugins/training-assets/lang/it.json | 3 +- plugins/training-assets/lang/ja.json | 3 +- plugins/training-assets/lang/ko.json | 3 +- plugins/training-assets/lang/pl.json | 3 +- plugins/training-assets/lang/pt-br.json | 3 +- plugins/training-assets/lang/pt.json | 3 +- plugins/training-assets/lang/ru.json | 3 +- plugins/training-assets/lang/tr.json | 3 +- plugins/training-assets/lang/zh.json | 3 +- .../src/components/Settings.svelte | 86 +++++++++++++------ plugins/training/src/index.ts | 1 + plugins/training/src/types.ts | 5 ++ .../training-resources/src/triggers.ts | 11 ++- 19 files changed, 108 insertions(+), 41 deletions(-) diff --git a/models/training/src/types.ts b/models/training/src/types.ts index de6037f9fb..2947ea208b 100644 --- a/models/training/src/types.ts +++ b/models/training/src/types.ts @@ -51,6 +51,7 @@ import { Mixin, Model, Prop, + TypeBoolean, TypeMarkup, TypeNumber, TypeRef, @@ -277,6 +278,9 @@ export class TTrainingsTypeData extends TTypedSpace implements RolesAssignment { @Mixin(training.mixin.TrainingReminderSettings, core.class.TypedSpace) @UX(getEmbeddedLabel('Training reminders'), training.icon.TrainingApplication) export class TTrainingReminderSettings extends TTypedSpace implements TrainingReminderSettings { + @Prop(TypeBoolean(), training.string.TrainingRemindersEnabled) + remindersEnabled: boolean = true + @Prop(ArrOf(TypeNumber()), training.string.TrainingReminderOffsetsDays) reminderOffsetsDays: number[] = [30, 7, 1] } diff --git a/plugins/training-assets/lang/cs.json b/plugins/training-assets/lang/cs.json index bfc89c24b4..e5553f16fe 100644 --- a/plugins/training-assets/lang/cs.json +++ b/plugins/training-assets/lang/cs.json @@ -106,6 +106,7 @@ "Permission_SendTrainingRequestReminder_Description": "Odesílat následná připomenutí účastníkům, kteří nedokončili žádost o školení", "SendReminder": "Odeslat připomenutí", "ConfirmSendReminder": "Odeslat připomenutí {count} účastníkům, kteří nedokončili toto školení?", - "ReminderSent": "Připomenutí odesláno {count} účastníkům" + "ReminderSent": "Připomenutí odesláno {count} účastníkům", + "TrainingRemindersEnabled": "Připomenutí termínu" } } diff --git a/plugins/training-assets/lang/de.json b/plugins/training-assets/lang/de.json index c7ae7aba43..169b06a39c 100644 --- a/plugins/training-assets/lang/de.json +++ b/plugins/training-assets/lang/de.json @@ -106,6 +106,7 @@ "Permission_SendTrainingRequestReminder_Description": "Folgeerinnerungen an Teilnehmer senden, die eine Schulungsanfrage nicht abgeschlossen haben", "SendReminder": "Erinnerung senden", "ConfirmSendReminder": "Erinnerung an {count} Teilnehmer senden, die diese Schulung nicht abgeschlossen haben?", - "ReminderSent": "Erinnerung an {count} Teilnehmer gesendet" + "ReminderSent": "Erinnerung an {count} Teilnehmer gesendet", + "TrainingRemindersEnabled": "Fristerinnerungen" } } diff --git a/plugins/training-assets/lang/en.json b/plugins/training-assets/lang/en.json index 6bb63227fd..ca97bac470 100644 --- a/plugins/training-assets/lang/en.json +++ b/plugins/training-assets/lang/en.json @@ -107,6 +107,7 @@ "TrainingReminderOffsetsDays": "Reminder lead times (days)", "SendReminder": "Send reminder", "ConfirmSendReminder": "Send a reminder to {count} trainee(s) who haven't completed this training?", - "ReminderSent": "Reminder sent to {count} trainee(s)" + "ReminderSent": "Reminder sent to {count} trainee(s)", + "TrainingRemindersEnabled": "Deadline reminders" } } diff --git a/plugins/training-assets/lang/es.json b/plugins/training-assets/lang/es.json index 9f9cb9f91b..1e20be1996 100644 --- a/plugins/training-assets/lang/es.json +++ b/plugins/training-assets/lang/es.json @@ -106,6 +106,7 @@ "Permission_SendTrainingRequestReminder_Description": "Enviar recordatorios de seguimiento a los alumnos que no han completado una solicitud de formación", "SendReminder": "Enviar recordatorio", "ConfirmSendReminder": "¿Enviar un recordatorio a {count} alumno(s) que no han completado esta formación?", - "ReminderSent": "Recordatorio enviado a {count} alumno(s)" + "ReminderSent": "Recordatorio enviado a {count} alumno(s)", + "TrainingRemindersEnabled": "Recordatorios de fecha límite" } } diff --git a/plugins/training-assets/lang/fr.json b/plugins/training-assets/lang/fr.json index 767fb9f488..875f78c4fc 100644 --- a/plugins/training-assets/lang/fr.json +++ b/plugins/training-assets/lang/fr.json @@ -106,6 +106,7 @@ "Permission_SendTrainingRequestReminder_Description": "Envoyer des rappels de suivi aux apprenants qui n'ont pas terminé une demande de formation", "SendReminder": "Envoyer un rappel", "ConfirmSendReminder": "Envoyer un rappel à {count} apprenant(s) n'ayant pas terminé cette formation ?", - "ReminderSent": "Rappel envoyé à {count} apprenant(s)" + "ReminderSent": "Rappel envoyé à {count} apprenant(s)", + "TrainingRemindersEnabled": "Rappels d'échéance" } } diff --git a/plugins/training-assets/lang/it.json b/plugins/training-assets/lang/it.json index d1ba3a3f04..1311b7a68d 100644 --- a/plugins/training-assets/lang/it.json +++ b/plugins/training-assets/lang/it.json @@ -106,6 +106,7 @@ "Permission_SendTrainingRequestReminder_Description": "Invia promemoria di follow-up ai partecipanti che non hanno completato una richiesta di formazione", "SendReminder": "Invia promemoria", "ConfirmSendReminder": "Inviare un promemoria a {count} partecipante/i che non hanno completato questa formazione?", - "ReminderSent": "Promemoria inviato a {count} partecipante/i" + "ReminderSent": "Promemoria inviato a {count} partecipante/i", + "TrainingRemindersEnabled": "Promemoria scadenza" } } diff --git a/plugins/training-assets/lang/ja.json b/plugins/training-assets/lang/ja.json index dbb1d2da8b..bc220a62d9 100644 --- a/plugins/training-assets/lang/ja.json +++ b/plugins/training-assets/lang/ja.json @@ -106,6 +106,7 @@ "Permission_SendTrainingRequestReminder_Description": "研修リクエストを完了していない受講者にフォローアップのリマインダーを送信する", "SendReminder": "リマインダーを送信", "ConfirmSendReminder": "この研修を完了していない{count}名の受講者にリマインダーを送信しますか?", - "ReminderSent": "{count}名の受講者にリマインダーを送信しました" + "ReminderSent": "{count}名の受講者にリマインダーを送信しました", + "TrainingRemindersEnabled": "期限リマインダー" } } diff --git a/plugins/training-assets/lang/ko.json b/plugins/training-assets/lang/ko.json index 8b0a08f017..52521a8a48 100644 --- a/plugins/training-assets/lang/ko.json +++ b/plugins/training-assets/lang/ko.json @@ -106,6 +106,7 @@ "Permission_SendTrainingRequestReminder_Description": "교육 요청을 완료하지 않은 학습자에게 후속 알림 보내기", "SendReminder": "알림 보내기", "ConfirmSendReminder": "이 교육을 완료하지 않은 학습자 {count}명에게 알림을 보내시겠습니까?", - "ReminderSent": "학습자 {count}명에게 알림을 보냈습니다" + "ReminderSent": "학습자 {count}명에게 알림을 보냈습니다", + "TrainingRemindersEnabled": "마감 알림" } } diff --git a/plugins/training-assets/lang/pl.json b/plugins/training-assets/lang/pl.json index c5e00ba652..87a0d3bfab 100644 --- a/plugins/training-assets/lang/pl.json +++ b/plugins/training-assets/lang/pl.json @@ -106,6 +106,7 @@ "Permission_SendTrainingRequestReminder_Description": "Wysyłaj przypomnienia do uczestników, którzy nie ukończyli żądania szkolenia", "SendReminder": "Wyślij przypomnienie", "ConfirmSendReminder": "Wysłać przypomnienie do {count} uczestników, którzy nie ukończyli tego szkolenia?", - "ReminderSent": "Przypomnienie wysłane do {count} uczestników" + "ReminderSent": "Przypomnienie wysłane do {count} uczestników", + "TrainingRemindersEnabled": "Przypomnienia o terminie" } } diff --git a/plugins/training-assets/lang/pt-br.json b/plugins/training-assets/lang/pt-br.json index f53ce66e63..b7364381ff 100644 --- a/plugins/training-assets/lang/pt-br.json +++ b/plugins/training-assets/lang/pt-br.json @@ -106,6 +106,7 @@ "Permission_SendTrainingRequestReminder_Description": "Enviar lembretes de acompanhamento aos participantes que não concluíram uma solicitação de treinamento", "SendReminder": "Enviar lembrete", "ConfirmSendReminder": "Enviar um lembrete a {count} participante(s) que não concluíram este treinamento?", - "ReminderSent": "Lembrete enviado a {count} participante(s)" + "ReminderSent": "Lembrete enviado a {count} participante(s)", + "TrainingRemindersEnabled": "Lembretes de prazo" } } diff --git a/plugins/training-assets/lang/pt.json b/plugins/training-assets/lang/pt.json index 6aab6f640c..6da0b91e50 100644 --- a/plugins/training-assets/lang/pt.json +++ b/plugins/training-assets/lang/pt.json @@ -106,6 +106,7 @@ "Permission_SendTrainingRequestReminder_Description": "Enviar lembretes de acompanhamento aos formandos que não concluíram um pedido de formação", "SendReminder": "Enviar lembrete", "ConfirmSendReminder": "Enviar um lembrete a {count} formando(s) que não concluíram esta formação?", - "ReminderSent": "Lembrete enviado a {count} formando(s)" + "ReminderSent": "Lembrete enviado a {count} formando(s)", + "TrainingRemindersEnabled": "Lembretes de prazo" } } diff --git a/plugins/training-assets/lang/ru.json b/plugins/training-assets/lang/ru.json index 5aca6438b0..f8158eb6d6 100644 --- a/plugins/training-assets/lang/ru.json +++ b/plugins/training-assets/lang/ru.json @@ -107,6 +107,7 @@ "TrainingReminderOffsetsDays": "Напоминания за (дней)", "SendReminder": "Отправить напоминание", "ConfirmSendReminder": "Отправить напоминание {count} обучаемым, не завершившим это обучение?", - "ReminderSent": "Напоминание отправлено {count} обучаемым" + "ReminderSent": "Напоминание отправлено {count} обучаемым", + "TrainingRemindersEnabled": "Напоминания о дедлайне" } } diff --git a/plugins/training-assets/lang/tr.json b/plugins/training-assets/lang/tr.json index 36f9346be2..c3206f8787 100644 --- a/plugins/training-assets/lang/tr.json +++ b/plugins/training-assets/lang/tr.json @@ -106,6 +106,7 @@ "Permission_SendTrainingRequestReminder_Description": "Bir eğitim talebini tamamlamayan katılımcılara takip hatırlatmaları gönder", "SendReminder": "Hatırlatma gönder", "ConfirmSendReminder": "Bu eğitimi tamamlamayan {count} katılımcıya hatırlatma gönderilsin mi?", - "ReminderSent": "{count} katılımcıya hatırlatma gönderildi" + "ReminderSent": "{count} katılımcıya hatırlatma gönderildi", + "TrainingRemindersEnabled": "Son tarih hatırlatmaları" } } diff --git a/plugins/training-assets/lang/zh.json b/plugins/training-assets/lang/zh.json index 8efd2d2839..c5908cc9bd 100644 --- a/plugins/training-assets/lang/zh.json +++ b/plugins/training-assets/lang/zh.json @@ -106,6 +106,7 @@ "Permission_SendTrainingRequestReminder_Description": "向未完成培训请求的学员发送跟进提醒", "SendReminder": "发送提醒", "ConfirmSendReminder": "向未完成此培训的 {count} 名学员发送提醒?", - "ReminderSent": "已向 {count} 名学员发送提醒" + "ReminderSent": "已向 {count} 名学员发送提醒", + "TrainingRemindersEnabled": "截止提醒" } } diff --git a/plugins/training-resources/src/components/Settings.svelte b/plugins/training-resources/src/components/Settings.svelte index f5fa132bce..d2eb7a7e4d 100644 --- a/plugins/training-resources/src/components/Settings.svelte +++ b/plugins/training-resources/src/components/Settings.svelte @@ -28,11 +28,14 @@ type WithLookup } from '@hcengineering/core' import { createQuery, getClient } from '@hcengineering/presentation' - import { Breadcrumb, EditBox, Header, Label, Loading } from '@hcengineering/ui' + import { Breadcrumb, Header, Label, Loading, Toggle } from '@hcengineering/ui' + import NullablePositiveNumberEditor from './NullablePositiveNumberEditor.svelte' import training from '../plugin' const DEFAULT_OFFSETS_DAYS = [30, 7, 1] + // Number of configurable reminders in the series (e.g. a month / a week / a day before the deadline). + const REMINDER_SLOTS = 3 const client = getClient() const hierarchy = client.getHierarchy() @@ -41,7 +44,8 @@ let spaceType: SpaceType | null = null let roles: Role[] | null = null let rolesAssignment: RolesAssignment | null = null - let reminderOffsetsInput: string = '' + let reminderOffsets: Array = new Array(REMINDER_SLOTS).fill(null) + let remindersEnabled: boolean = true $: { query.query( core.class.TypedSpace, @@ -67,10 +71,13 @@ } if (space !== null) { - const offsets = hierarchy.hasMixin(space, training.mixin.TrainingReminderSettings) - ? (hierarchy.as(space, training.mixin.TrainingReminderSettings).reminderOffsetsDays ?? DEFAULT_OFFSETS_DAYS) - : DEFAULT_OFFSETS_DAYS - reminderOffsetsInput = offsets.join(', ') + const settings = hierarchy.hasMixin(space, training.mixin.TrainingReminderSettings) + ? hierarchy.as(space, training.mixin.TrainingReminderSettings) + : undefined + remindersEnabled = settings?.remindersEnabled !== false + const configured = settings?.reminderOffsetsDays ?? DEFAULT_OFFSETS_DAYS + const sorted = [...(configured.length > 0 ? configured : DEFAULT_OFFSETS_DAYS)].sort((a, b) => b - a) + reminderOffsets = Array.from({ length: REMINDER_SLOTS }, (_, i) => sorted[i] ?? null) } }, { @@ -91,21 +98,29 @@ await client.updateMixin(space._id, space._class, space.space, spaceType.targetClass, rolesAssignment) } - // Parse a comma/space separated list of positive day offsets, de-duplicated and sorted descending. - function parseOffsets (input: string): number[] { - const parsed = input - .split(/[\s,]+/) - .map((s) => Number.parseInt(s, 10)) - .filter((n) => Number.isFinite(n) && n > 0) - return Array.from(new Set(parsed)).sort((a, b) => b - a) + // Each slot is already constrained to a positive integer (or null) by NullablePositiveNumberEditor; + // here we just drop empty slots, de-duplicate and store the series sorted descending. + async function onOffsetChange (index: number, value: number | null): Promise { + if (space === null) return + + reminderOffsets[index] = value + reminderOffsets = reminderOffsets + + const reminderOffsetsDays = Array.from( + new Set(reminderOffsets.filter((v): v is number => v !== null && v > 0)) + ).sort((a, b) => b - a) + + await client.updateMixin(space._id, space._class, space.space, training.mixin.TrainingReminderSettings, { + reminderOffsetsDays + }) } - async function onOffsetsChange (): Promise { + // Disabling keeps the deadline visible on trainees' calendars — it only stops the reminders. + async function onRemindersEnabledChange (enabled: boolean): Promise { if (space === null) return - const reminderOffsetsDays = parseOffsets(reminderOffsetsInput) - reminderOffsetsInput = reminderOffsetsDays.join(', ') + remindersEnabled = enabled await client.updateMixin(space._id, space._class, space.space, training.mixin.TrainingReminderSettings, { - reminderOffsetsDays + remindersEnabled: enabled }) } @@ -136,16 +151,33 @@ {/each}
-
- { - void onOffsetsChange() + { + void onRemindersEnabledChange(ev.detail) }} /> + +
+
+
+ {#each reminderOffsets as offset, index} + { + void onOffsetChange(index, value) + }} + /> + {/each} +
{/if} @@ -156,6 +188,12 @@ margin: 2rem 3.25rem; } + .offsets { + display: flex; + align-items: center; + gap: 0.5rem; + } + .grid { display: grid; grid-template-columns: max-content 1fr; diff --git a/plugins/training/src/index.ts b/plugins/training/src/index.ts index a3cf3855e4..116d04f8c1 100644 --- a/plugins/training/src/index.ts +++ b/plugins/training/src/index.ts @@ -100,6 +100,7 @@ export default plugin(trainingId, { TrainingReleasedBy: '' as IntlString, TrainingReleasedOn: '' as IntlString, TrainingReminderOffsetsDays: '' as IntlString, + TrainingRemindersEnabled: '' as IntlString, TrainingRequest: '' as IntlString, TrainingRequestAssign: '' as IntlString, TrainingRequestAttempt: '' as IntlString, diff --git a/plugins/training/src/types.ts b/plugins/training/src/types.ts index e52528ff04..68e075e55e 100644 --- a/plugins/training/src/types.ts +++ b/plugins/training/src/types.ts @@ -80,6 +80,11 @@ export interface TrainingRequest extends AttachedDoc x - y) + const sa = [...(a ?? [])].sort((x, y) => x - y) const sb = [...b].sort((x, y) => x - y) + if (sa.length !== sb.length) return false return sa.every((v, i) => v === sb[i]) } /** * Resolve the reminder lead-time series (in ms before `dueDate`) configured for a Trainings space. - * Reads the {@link training.mixin.TrainingReminderSettings} mixin on the space; falls back to a default. + * Reads the {@link training.mixin.TrainingReminderSettings} mixin on the space; falls back to a default + * when the space was never configured. + * + * Returns an empty series when reminders are disabled — the deadline event is still created (so the + * deadline stays visible on the trainee's calendar), it just carries no reminders to schedule. */ async function resolveReminderOffsetsMs (control: TriggerControl, spaceRef: Ref): Promise { let days = DEFAULT_OFFSETS_DAYS const space = (await control.findAll(control.ctx, core.class.TypedSpace, { _id: spaceRef }, { limit: 1 }))[0] if (space !== undefined && control.hierarchy.hasMixin(space, training.mixin.TrainingReminderSettings)) { const settings = control.hierarchy.as(space, training.mixin.TrainingReminderSettings) + if (settings.remindersEnabled === false) return [] if (Array.isArray(settings.reminderOffsetsDays) && settings.reminderOffsetsDays.length > 0) { days = settings.reminderOffsetsDays } From f23d3d4c5ca5bd5062b5eec016fdba6a6958f325 Mon Sep 17 00:00:00 2001 From: Artem Savchenko Date: Thu, 16 Jul 2026 17:00:24 +0700 Subject: [PATCH 3/4] Fix event display Signed-off-by: Artem Savchenko --- .../training-resources/src/triggers.ts | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/server-plugins/training-resources/src/triggers.ts b/server-plugins/training-resources/src/triggers.ts index cdd74400e5..504c27bd62 100644 --- a/server-plugins/training-resources/src/triggers.ts +++ b/server-plugins/training-resources/src/triggers.ts @@ -21,6 +21,7 @@ import core, { pickPrimarySocialId, type Ref, type Space, + type Timestamp, type Tx, type TxCreateDoc, type TxMixin, @@ -40,6 +41,19 @@ import training, { const DAY_MS = 24 * 60 * 60 * 1000 const DEFAULT_OFFSETS_DAYS = [30, 7, 1] +// Matches `allDayDuration` used by the calendar UI when creating all-day events. +const ALL_DAY_DURATION_MS = DAY_MS - 1 + +/** + * All-day events are stored day-aligned on UTC midnight and span the whole day (the calendar UI does + * the same via `setHours(0,0,0,0)` + `saveUTC`). A zero-length event would be rendered without a title: + * `EventElement` treats anything shorter than 25 minutes as `narrow` and hides the label. + */ +function allDayRange (ts: Timestamp): { date: Timestamp, dueDate: Timestamp } { + const d = new Date(ts) + const date = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()) + return { date, dueDate: date + ALL_DAY_DURATION_MS } +} function sameOffsets (a: number[] | undefined, b: number[]): boolean { const sa = [...(a ?? [])].sort((x, y) => x - y) @@ -93,6 +107,7 @@ async function buildDeadlineEventTx ( const calendars = await control.findAll(control.ctx, calendar.class.Calendar, { user: primary, hidden: false }) const cal = getPrimaryCalendar(calendars, undefined, acc) + const { date, dueDate } = allDayRange(request.dueDate) const data: Data = { attachedTo: request._id, @@ -103,8 +118,8 @@ async function buildDeadlineEventTx ( title, description: '', allDay: true, - date: request.dueDate, - dueDate: request.dueDate, + date, + dueDate, participants: [trainee as unknown as Ref], reminders: offsetsMs, access: AccessLevel.Reader, @@ -150,9 +165,10 @@ async function reconcileDeadlineEvents (control: TriggerControl, requestId: Ref< if (tx !== undefined) res.push(tx) } else { const ops: DocumentUpdate = {} - if (ev.date !== request.dueDate) { - ops.date = request.dueDate - ops.dueDate = request.dueDate + const { date, dueDate } = allDayRange(request.dueDate) + if (ev.date !== date || ev.dueDate !== dueDate) { + ops.date = date + ops.dueDate = dueDate } if (!sameOffsets(ev.reminders, offsetsMs)) { ops.reminders = offsetsMs From 730b2a301607cd3c19c7d49777fcb51be7ccdb3a Mon Sep 17 00:00:00 2001 From: Artem Savchenko Date: Thu, 16 Jul 2026 21:48:37 +0700 Subject: [PATCH 4/4] Update wording and fix test Signed-off-by: Artem Savchenko --- models/training/src/index.ts | 3 ++- plugins/training-assets/lang/cs.json | 3 ++- plugins/training-assets/lang/de.json | 3 ++- plugins/training-assets/lang/en.json | 3 ++- plugins/training-assets/lang/es.json | 3 ++- plugins/training-assets/lang/fr.json | 3 ++- plugins/training-assets/lang/it.json | 3 ++- plugins/training-assets/lang/ja.json | 3 ++- plugins/training-assets/lang/ko.json | 3 ++- plugins/training-assets/lang/pl.json | 3 ++- plugins/training-assets/lang/pt-br.json | 3 ++- plugins/training-assets/lang/pt.json | 3 ++- plugins/training-assets/lang/ru.json | 3 ++- plugins/training-assets/lang/tr.json | 3 ++- plugins/training-assets/lang/zh.json | 3 ++- plugins/training/src/index.ts | 1 + .../pod-events-processor/src/worker.ts | 18 +++++++++++++++++- ws-tests/api-tests/src/__tests__/rest.test.ts | 2 +- 18 files changed, 49 insertions(+), 17 deletions(-) diff --git a/models/training/src/index.ts b/models/training/src/index.ts index f1ae090cff..3df2534afb 100644 --- a/models/training/src/index.ts +++ b/models/training/src/index.ts @@ -646,7 +646,8 @@ function defineTrainingRequest (builder: Builder): void { notificationType: training.ids.TrainingDeadlineReminder, headerIcon: training.icon.TrainingRequest, header: training.string.TrainingDeadlineReminder, - message: training.string.TrainingDeadlineReminder + // Interpolated by the events-processor with the generic `{ title, days }` params. + message: training.string.TrainingDeadlineReminderMessage }) builder.createDoc( diff --git a/plugins/training-assets/lang/cs.json b/plugins/training-assets/lang/cs.json index e5553f16fe..f0ecce596f 100644 --- a/plugins/training-assets/lang/cs.json +++ b/plugins/training-assets/lang/cs.json @@ -107,6 +107,7 @@ "SendReminder": "Odeslat připomenutí", "ConfirmSendReminder": "Odeslat připomenutí {count} účastníkům, kteří nedokončili toto školení?", "ReminderSent": "Připomenutí odesláno {count} účastníkům", - "TrainingRemindersEnabled": "Připomenutí termínu" + "TrainingRemindersEnabled": "Připomenutí termínu", + "TrainingDeadlineReminderMessage": "{title} — {days, plural, =0 {termín je dnes} one {zbývá # den} few {zbývají # dny} other {zbývá # dní}}" } } diff --git a/plugins/training-assets/lang/de.json b/plugins/training-assets/lang/de.json index 169b06a39c..ddb7e2fcb1 100644 --- a/plugins/training-assets/lang/de.json +++ b/plugins/training-assets/lang/de.json @@ -107,6 +107,7 @@ "SendReminder": "Erinnerung senden", "ConfirmSendReminder": "Erinnerung an {count} Teilnehmer senden, die diese Schulung nicht abgeschlossen haben?", "ReminderSent": "Erinnerung an {count} Teilnehmer gesendet", - "TrainingRemindersEnabled": "Fristerinnerungen" + "TrainingRemindersEnabled": "Fristerinnerungen", + "TrainingDeadlineReminderMessage": "{title} — {days, plural, =0 {heute fällig} one {fällig in # Tag} other {fällig in # Tagen}}" } } diff --git a/plugins/training-assets/lang/en.json b/plugins/training-assets/lang/en.json index ca97bac470..5dea6d2dd1 100644 --- a/plugins/training-assets/lang/en.json +++ b/plugins/training-assets/lang/en.json @@ -108,6 +108,7 @@ "SendReminder": "Send reminder", "ConfirmSendReminder": "Send a reminder to {count} trainee(s) who haven't completed this training?", "ReminderSent": "Reminder sent to {count} trainee(s)", - "TrainingRemindersEnabled": "Deadline reminders" + "TrainingRemindersEnabled": "Deadline reminders", + "TrainingDeadlineReminderMessage": "{title} — {days, plural, =0 {due today} =1 {due in # day} other {due in # days}}" } } diff --git a/plugins/training-assets/lang/es.json b/plugins/training-assets/lang/es.json index 1e20be1996..88785df726 100644 --- a/plugins/training-assets/lang/es.json +++ b/plugins/training-assets/lang/es.json @@ -107,6 +107,7 @@ "SendReminder": "Enviar recordatorio", "ConfirmSendReminder": "¿Enviar un recordatorio a {count} alumno(s) que no han completado esta formación?", "ReminderSent": "Recordatorio enviado a {count} alumno(s)", - "TrainingRemindersEnabled": "Recordatorios de fecha límite" + "TrainingRemindersEnabled": "Recordatorios de fecha límite", + "TrainingDeadlineReminderMessage": "{title} — {days, plural, =0 {vence hoy} one {vence en # día} other {vence en # días}}" } } diff --git a/plugins/training-assets/lang/fr.json b/plugins/training-assets/lang/fr.json index 875f78c4fc..a7a1d23f12 100644 --- a/plugins/training-assets/lang/fr.json +++ b/plugins/training-assets/lang/fr.json @@ -107,6 +107,7 @@ "SendReminder": "Envoyer un rappel", "ConfirmSendReminder": "Envoyer un rappel à {count} apprenant(s) n'ayant pas terminé cette formation ?", "ReminderSent": "Rappel envoyé à {count} apprenant(s)", - "TrainingRemindersEnabled": "Rappels d'échéance" + "TrainingRemindersEnabled": "Rappels d'échéance", + "TrainingDeadlineReminderMessage": "{title} — {days, plural, =0 {échéance aujourd'hui} one {échéance dans # jour} other {échéance dans # jours}}" } } diff --git a/plugins/training-assets/lang/it.json b/plugins/training-assets/lang/it.json index 1311b7a68d..1bc3972d42 100644 --- a/plugins/training-assets/lang/it.json +++ b/plugins/training-assets/lang/it.json @@ -107,6 +107,7 @@ "SendReminder": "Invia promemoria", "ConfirmSendReminder": "Inviare un promemoria a {count} partecipante/i che non hanno completato questa formazione?", "ReminderSent": "Promemoria inviato a {count} partecipante/i", - "TrainingRemindersEnabled": "Promemoria scadenza" + "TrainingRemindersEnabled": "Promemoria scadenza", + "TrainingDeadlineReminderMessage": "{title} — {days, plural, =0 {scade oggi} one {scade tra # giorno} other {scade tra # giorni}}" } } diff --git a/plugins/training-assets/lang/ja.json b/plugins/training-assets/lang/ja.json index bc220a62d9..0943719920 100644 --- a/plugins/training-assets/lang/ja.json +++ b/plugins/training-assets/lang/ja.json @@ -107,6 +107,7 @@ "SendReminder": "リマインダーを送信", "ConfirmSendReminder": "この研修を完了していない{count}名の受講者にリマインダーを送信しますか?", "ReminderSent": "{count}名の受講者にリマインダーを送信しました", - "TrainingRemindersEnabled": "期限リマインダー" + "TrainingRemindersEnabled": "期限リマインダー", + "TrainingDeadlineReminderMessage": "{title} — {days, plural, =0 {本日が期限です} other {期限まであと # 日}}" } } diff --git a/plugins/training-assets/lang/ko.json b/plugins/training-assets/lang/ko.json index 52521a8a48..24d279ee7d 100644 --- a/plugins/training-assets/lang/ko.json +++ b/plugins/training-assets/lang/ko.json @@ -107,6 +107,7 @@ "SendReminder": "알림 보내기", "ConfirmSendReminder": "이 교육을 완료하지 않은 학습자 {count}명에게 알림을 보내시겠습니까?", "ReminderSent": "학습자 {count}명에게 알림을 보냈습니다", - "TrainingRemindersEnabled": "마감 알림" + "TrainingRemindersEnabled": "마감 알림", + "TrainingDeadlineReminderMessage": "{title} — {days, plural, =0 {오늘 마감} other {마감까지 # 일}}" } } diff --git a/plugins/training-assets/lang/pl.json b/plugins/training-assets/lang/pl.json index 87a0d3bfab..0ad91317a9 100644 --- a/plugins/training-assets/lang/pl.json +++ b/plugins/training-assets/lang/pl.json @@ -107,6 +107,7 @@ "SendReminder": "Wyślij przypomnienie", "ConfirmSendReminder": "Wysłać przypomnienie do {count} uczestników, którzy nie ukończyli tego szkolenia?", "ReminderSent": "Przypomnienie wysłane do {count} uczestników", - "TrainingRemindersEnabled": "Przypomnienia o terminie" + "TrainingRemindersEnabled": "Przypomnienia o terminie", + "TrainingDeadlineReminderMessage": "{title} — {days, plural, =0 {termin dzisiaj} one {pozostał # dzień} few {pozostały # dni} many {pozostało # dni} other {pozostało # dni}}" } } diff --git a/plugins/training-assets/lang/pt-br.json b/plugins/training-assets/lang/pt-br.json index b7364381ff..825edb2d83 100644 --- a/plugins/training-assets/lang/pt-br.json +++ b/plugins/training-assets/lang/pt-br.json @@ -107,6 +107,7 @@ "SendReminder": "Enviar lembrete", "ConfirmSendReminder": "Enviar um lembrete a {count} participante(s) que não concluíram este treinamento?", "ReminderSent": "Lembrete enviado a {count} participante(s)", - "TrainingRemindersEnabled": "Lembretes de prazo" + "TrainingRemindersEnabled": "Lembretes de prazo", + "TrainingDeadlineReminderMessage": "{title} — {days, plural, =0 {vence hoje} one {vence em # dia} other {vence em # dias}}" } } diff --git a/plugins/training-assets/lang/pt.json b/plugins/training-assets/lang/pt.json index 6da0b91e50..7d477ebb91 100644 --- a/plugins/training-assets/lang/pt.json +++ b/plugins/training-assets/lang/pt.json @@ -107,6 +107,7 @@ "SendReminder": "Enviar lembrete", "ConfirmSendReminder": "Enviar um lembrete a {count} formando(s) que não concluíram esta formação?", "ReminderSent": "Lembrete enviado a {count} formando(s)", - "TrainingRemindersEnabled": "Lembretes de prazo" + "TrainingRemindersEnabled": "Lembretes de prazo", + "TrainingDeadlineReminderMessage": "{title} — {days, plural, =0 {termina hoje} one {termina em # dia} other {termina em # dias}}" } } diff --git a/plugins/training-assets/lang/ru.json b/plugins/training-assets/lang/ru.json index f8158eb6d6..427d92758d 100644 --- a/plugins/training-assets/lang/ru.json +++ b/plugins/training-assets/lang/ru.json @@ -108,6 +108,7 @@ "SendReminder": "Отправить напоминание", "ConfirmSendReminder": "Отправить напоминание {count} обучаемым, не завершившим это обучение?", "ReminderSent": "Напоминание отправлено {count} обучаемым", - "TrainingRemindersEnabled": "Напоминания о дедлайне" + "TrainingRemindersEnabled": "Напоминания о дедлайне", + "TrainingDeadlineReminderMessage": "{title} — {days, plural, =0 {дедлайн сегодня} one {остался # день} few {осталось # дня} many {осталось # дней} other {осталось # дней}}" } } diff --git a/plugins/training-assets/lang/tr.json b/plugins/training-assets/lang/tr.json index c3206f8787..db880f8131 100644 --- a/plugins/training-assets/lang/tr.json +++ b/plugins/training-assets/lang/tr.json @@ -107,6 +107,7 @@ "SendReminder": "Hatırlatma gönder", "ConfirmSendReminder": "Bu eğitimi tamamlamayan {count} katılımcıya hatırlatma gönderilsin mi?", "ReminderSent": "{count} katılımcıya hatırlatma gönderildi", - "TrainingRemindersEnabled": "Son tarih hatırlatmaları" + "TrainingRemindersEnabled": "Son tarih hatırlatmaları", + "TrainingDeadlineReminderMessage": "{title} — {days, plural, =0 {bugün son gün} other {son tarihe # gün}}" } } diff --git a/plugins/training-assets/lang/zh.json b/plugins/training-assets/lang/zh.json index c5908cc9bd..77728e3005 100644 --- a/plugins/training-assets/lang/zh.json +++ b/plugins/training-assets/lang/zh.json @@ -107,6 +107,7 @@ "SendReminder": "发送提醒", "ConfirmSendReminder": "向未完成此培训的 {count} 名学员发送提醒?", "ReminderSent": "已向 {count} 名学员发送提醒", - "TrainingRemindersEnabled": "截止提醒" + "TrainingRemindersEnabled": "截止提醒", + "TrainingDeadlineReminderMessage": "{title} — {days, plural, =0 {今天截止} other {距截止还有 # 天}}" } } diff --git a/plugins/training/src/index.ts b/plugins/training/src/index.ts index 116d04f8c1..e9b36b6668 100644 --- a/plugins/training/src/index.ts +++ b/plugins/training/src/index.ts @@ -91,6 +91,7 @@ export default plugin(trainingId, { TrainingCreate: '' as IntlString, TrainingCreated: '' as IntlString, TrainingDeadlineReminder: '' as IntlString, + TrainingDeadlineReminderMessage: '' as IntlString, TrainingDocuments: '' as IntlString, TrainingOverview: '' as IntlString, TrainingPassingScore: '' as IntlString, diff --git a/services/notification/pod-events-processor/src/worker.ts b/services/notification/pod-events-processor/src/worker.ts index 89d26bce26..c4a90180e9 100644 --- a/services/notification/pod-events-processor/src/worker.ts +++ b/services/notification/pod-events-processor/src/worker.ts @@ -38,6 +38,8 @@ import time, { type ToDo } from '@hcengineering/time' import { getClient, type ClientBundle } from './client' import type { ScheduledNotificationMessage } from './types' +const DAY_MS = 24 * 60 * 60 * 1000 + interface MinimalEvent extends Doc { attachedTo?: Ref attachedToClass?: Ref> @@ -135,6 +137,14 @@ export async function handleScheduledNotification ( } await control.heartbeat() + + // Generic params any reminder message may interpolate. `shiftMs` is the configured lead time, so at + // fire time it is exactly how long is left until the event — no clock arithmetic needed. + const params = { + title: target.titleText, + days: Math.max(0, Math.round(msg.shiftMs / DAY_MS)) + } + try { await client.createDoc( notification.class.CommonInboxNotification, @@ -145,8 +155,14 @@ export async function handleScheduledNotification ( objectClass: target.objectClass, headerIcon: target.headerIcon ?? calendar.icon.Reminder, header: target.header ?? calendar.string.Reminder, + intlParams: params, message: target.message ?? calendar.string.Reminder, - messageHtml: jsonToMarkup(nodeDoc(nodeParagraph(nodeText(target.titleText)))), + props: params, + // `messageHtml` wins over `message` in the inbox presenter, so it is only used as the plain + // calendar fallback (raw event title). A declarative presenter supplies its own parameterised + // `message` instead, which renders with `props`. + messageHtml: + target.message === undefined ? jsonToMarkup(nodeDoc(nodeParagraph(nodeText(target.titleText)))) : undefined, types: target.notificationTypes ?? [calendar.ids.ReminderNotification], isViewed: false, archived: false, diff --git a/ws-tests/api-tests/src/__tests__/rest.test.ts b/ws-tests/api-tests/src/__tests__/rest.test.ts index fff8edfafd..cbeadfdabc 100644 --- a/ws-tests/api-tests/src/__tests__/rest.test.ts +++ b/ws-tests/api-tests/src/__tests__/rest.test.ts @@ -222,7 +222,7 @@ describe('rest-api-server', () => { const { hierarchy, model } = await conn.getModel() const dsc = hierarchy.getDescendants(core.class.Space) - expect(dsc.length).toBe(33) // todo fix this stupid test + expect(dsc.length).toBe(34) // todo fix this stupid test expect(model.getObject(core.class.Space)).not.toBeNull() })