@prorigo/protrak-forge 0.3.3 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/bin/protrak-forge.js +642 -220
- package/data/CLAUDE.md.template +116 -21
- package/data/patterns/notification-template-reference.md +191 -0
- package/package.json +1 -1
- package/data/patterns/aggregate-related-instances.md +0 -140
package/data/CLAUDE.md.template
CHANGED
|
@@ -7,23 +7,80 @@ that implement Protrak interfaces (PreCreate, PostCreate, PreUpdate, PromoteActi
|
|
|
7
7
|
|
|
8
8
|
## How to write Protrak programs
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
Default to the Protrak Forge MCP tools, not the filesystem. The cost-of-skipping table at the end
|
|
11
|
+
of this section makes the trade-off concrete; the workflows below tell you which tools to call.
|
|
11
12
|
|
|
12
|
-
### Writing a
|
|
13
|
-
1. Call `get_protrak_program_context
|
|
14
|
-
|
|
15
|
-
in ONE call.
|
|
16
|
-
2. Write the C#
|
|
17
|
-
3. Call `validate_protrak_program`
|
|
13
|
+
### Writing a NEW program:
|
|
14
|
+
1. Call `get_protrak_program_context(type_name, program_type, description)` — returns type
|
|
15
|
+
schema, C# interface contract, coding patterns, similar programs, and available query
|
|
16
|
+
definitions in ONE call.
|
|
17
|
+
2. Write the C# source.
|
|
18
|
+
3. Call `validate_protrak_program(code, program_type)` — see "What this catches" below.
|
|
19
|
+
4. Call `generate_protrak_program(program_name, program_type, source)` — writes BOTH
|
|
20
|
+
`Programs/<name>.cs` AND `Programs/<name>.json` (companion metadata required by Protrak's
|
|
21
|
+
import flow). **Do NOT use the Write tool here** — you will skip the .json companion and
|
|
22
|
+
the program will not import.
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
get_protrak_program_context(type_name='Project', program_type='PreUpdate', description='block status change when invoice unpaid')
|
|
26
|
+
# ... agent writes the C# source ...
|
|
27
|
+
validate_protrak_program(code='<source>', program_type='PreUpdate')
|
|
28
|
+
generate_protrak_program(program_name='ProjectPreUpdate', program_type='PreUpdateTrigger', source='<source>')
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Editing an EXISTING program:
|
|
32
|
+
1. Call `get_protrak_program_context(type_name, program_type, program_name='<ExistingName>')`.
|
|
33
|
+
Passing `program_name` switches the tool to refactor mode — response includes
|
|
34
|
+
`existing_source`, `existing_services_used`, `existing_patterns_detected`, and
|
|
35
|
+
`existing_class_name` alongside the usual schema/interface/patterns context.
|
|
36
|
+
**One call** — no need to chain `read_protrak_program` separately.
|
|
37
|
+
2. Edit the C# source.
|
|
38
|
+
3. Call `validate_protrak_program(code, program_type)` on the edited source.
|
|
39
|
+
4. Write the edited source back to the existing `Programs/<name>.cs` file. Do NOT call
|
|
40
|
+
`generate_protrak_program` for refactors — the companion .json already exists.
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
get_protrak_program_context(type_name='Project', program_type='PreUpdate', program_name='CalculateNextInvoiceDate')
|
|
44
|
+
# ... agent edits existing_source ...
|
|
45
|
+
validate_protrak_program(code='<edited source>', program_type='PreUpdate')
|
|
46
|
+
# Write edited source to existing_file_path
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### What `validate_protrak_program` catches (and what you skip if you don't run it):
|
|
50
|
+
|
|
51
|
+
| Check | What breaks if you ship it |
|
|
52
|
+
| ----------------------------- | ------------------------------------------------ |
|
|
53
|
+
| `.Result` / `.Wait()` | Deadlock under Protrak's async context |
|
|
54
|
+
| `async void` | Exceptions escape silently |
|
|
55
|
+
| Service as private/readonly | Protrak DI requires PUBLIC PROPERTIES — won't inject |
|
|
56
|
+
| Missing interface | PreCreate without `IPreCreateTriggerProgramAsync<T>` — Protrak can't bind it |
|
|
57
|
+
| `*Async()` without `await` | Silent fire-and-forget |
|
|
58
|
+
| Service not declared as prop | Runtime null-ref on first use |
|
|
59
|
+
| Missing namespace | Protrak import rejects the file |
|
|
60
|
+
|
|
61
|
+
These are **Protrak-specific gotchas you cannot catch by reading the code**. Always run
|
|
62
|
+
`validate_protrak_program` after writing or editing.
|
|
63
|
+
|
|
64
|
+
### Cost of skipping the MCP tools:
|
|
65
|
+
|
|
66
|
+
| Skip | What you miss |
|
|
67
|
+
| ----------------------------- | -------------------------------------------------------------- |
|
|
68
|
+
| `get_protrak_program_context` | Wrong interface, wrong service injection, wrong query patterns |
|
|
69
|
+
| Refactor mode (`program_name`)| Re-derive existing source from disk; miss `services_used` and `patterns_detected` |
|
|
70
|
+
| `validate_protrak_program` | Deadlocks, async-void bugs, DI shape mismatches in production |
|
|
71
|
+
| `generate_protrak_program` | No companion `.json` — Protrak won't import the program |
|
|
18
72
|
|
|
19
73
|
### Understanding the workspace:
|
|
20
|
-
-
|
|
21
|
-
`
|
|
22
|
-
-
|
|
23
|
-
element — for `kind='type'` you get the resolved schema with attributes, lifecycle and
|
|
74
|
+
- `list_protrak_schema_elements` (optionally with `kind`: `type`, `attribute`, `lifecycle`,
|
|
75
|
+
`relation_type`) catalogs what's defined.
|
|
76
|
+
- `read_protrak_schema_element` (with `kind` and `name`) returns the full JSON of any one
|
|
77
|
+
element — for `kind='type'` you get the resolved schema with attributes, lifecycle, and
|
|
24
78
|
relations hydrated.
|
|
25
|
-
-
|
|
26
|
-
|
|
79
|
+
- `list_protrak_programs` lists all programs; `read_protrak_program(program_name)` returns
|
|
80
|
+
source + metadata for one (but for refactor work, prefer `get_protrak_program_context`
|
|
81
|
+
with `program_name` — it returns source AND full context together).
|
|
82
|
+
- `search_protrak_knowledge(query, scope)` runs relevance-ranked search across patterns,
|
|
83
|
+
C# API docs, and workspace programs.
|
|
27
84
|
|
|
28
85
|
### Scaffolding schema elements (Types, Lifecycles, Attributes, RelationTypes):
|
|
29
86
|
- For a brand-new entity, call `scaffold_protrak_entity` once with the type name, the
|
|
@@ -49,17 +106,55 @@ that implement Protrak interfaces (PreCreate, PostCreate, PreUpdate, PromoteActi
|
|
|
49
106
|
- All schema names must be PascalCase. Aliases for `attribute_type`: `Number`→`Numeric`,
|
|
50
107
|
`File`→`Attachment`.
|
|
51
108
|
|
|
52
|
-
### Key Protrak rules:
|
|
53
|
-
- Services are **public properties**, never constructor parameters
|
|
54
|
-
|
|
55
|
-
-
|
|
56
|
-
-
|
|
57
|
-
|
|
58
|
-
-
|
|
109
|
+
### Key Protrak rules (each cross-references a `validate_protrak_program` check):
|
|
110
|
+
- Services are **public properties**, never constructor parameters — checked by
|
|
111
|
+
`service_as_public_property`.
|
|
112
|
+
- Implement the exact program-type interface — checked by `interface_compliance`.
|
|
113
|
+
- Prefer async interface variants (`RunAsync` not `Run`); never call `.Result` / `.Wait()`
|
|
114
|
+
— checked by `no_dot_result`.
|
|
115
|
+
- Never write `async void` — checked by `no_async_void`.
|
|
116
|
+
- `await` or `return` every `*Async()` call — checked by `missing_await`.
|
|
117
|
+
- Declare every service used as a public property — checked by `undeclared_service`.
|
|
118
|
+
- Declare a `namespace` — checked by `missing_namespace`.
|
|
119
|
+
- Always null-check attributes: `instance.Attributes?["Name"]?.Value` (style rule, not
|
|
120
|
+
validated by static analysis).
|
|
121
|
+
- Use typed accessors: `GetTextAttributeValue`, `GetDateAttributeValue`,
|
|
122
|
+
`GetPicklistAttributeValue` (style rule).
|
|
123
|
+
- Use `SetAttributeValue(name, AttributeType, value)` to write attributes (style rule).
|
|
124
|
+
- Return `ProgramResult { IsSuccess = false, Message = "..." }` to block operations in
|
|
125
|
+
Pre-triggers (style rule).
|
|
126
|
+
|
|
127
|
+
## How to write notification templates
|
|
128
|
+
|
|
129
|
+
Notification templates define the email/push content sent by Protrak. They use **Razor syntax**
|
|
130
|
+
(`@Model.*`), not `{{}}` placeholders.
|
|
131
|
+
|
|
132
|
+
### Writing a new notification template:
|
|
133
|
+
1. Call `get_protrak_notification_context(type_name?)` — returns existing templates and the full
|
|
134
|
+
`@Model` variable reference for all target types.
|
|
135
|
+
2. Write the Razor HTML body using `@Model.*` variables.
|
|
136
|
+
3. Call `generate_protrak_notification_template(name='<Title> Body', target='PromoteNotification', email_html='<Razor HTML>')`.
|
|
137
|
+
4. Call again for the subject: `generate_protrak_notification_template(name='<Title> Subject', target='PromoteNotification', email_html='<subject line>')`.
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
get_protrak_notification_context(type_name='Invoice')
|
|
141
|
+
# ... agent writes the Razor HTML ...
|
|
142
|
+
generate_protrak_notification_template(name='Invoice Sent Body', target='PromoteNotification', email_html='<p>Hi @Model.AttributeValues["Manager"].UserValues[0].UserName,...</p>')
|
|
143
|
+
generate_protrak_notification_template(name='Invoice Sent Subject', target='PromoteNotification', email_html='Invoice @Model.InstanceName has been sent')
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Each call creates `NotificationTemplates/<name>.json` (metadata) and `NotificationTemplates/<name>.html`
|
|
147
|
+
(email body). `hashCode` is left empty — Protrak recomputes it on import.
|
|
148
|
+
|
|
149
|
+
Valid `target` values: `PromoteNotification`, `AccountNotification`, `WorkflowNotification`,
|
|
150
|
+
`WorkflowTaskNotification`.
|
|
59
151
|
|
|
60
152
|
## Project structure
|
|
61
153
|
- `Types/` — entity type JSON definitions
|
|
62
154
|
- `Attributes/` — attribute JSON definitions
|
|
63
155
|
- `Lifecycles/` — lifecycle state machine definitions
|
|
64
|
-
- `Programs/` — C# program source + JSON metadata
|
|
156
|
+
- `Programs/` — C# program source + JSON metadata (paired files; use
|
|
157
|
+
`generate_protrak_program` to keep them in sync)
|
|
65
158
|
- `RelationTypes/` — relation definitions between types
|
|
159
|
+
- `NotificationTemplates/` — notification template files (paired `.json` + `.html`; use
|
|
160
|
+
`generate_protrak_notification_template` to keep them in sync)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# Notification Template Reference
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Protrak notification templates define the content of notifications (email + push) triggered by
|
|
6
|
+
platform events. Templates use **Razor markup syntax** (`@Model.*`) and are stored as paired files:
|
|
7
|
+
|
|
8
|
+
- `NotificationTemplates/<title>.json` — metadata (title, target, push message, state)
|
|
9
|
+
- `NotificationTemplates/<title>.html` — HTML email body (Razor template)
|
|
10
|
+
|
|
11
|
+
Two templates are needed for a complete email notification: one for the **subject** (title ending
|
|
12
|
+
in `Subject`) and one for the **body** (title ending in `Body`). Push-only templates need only
|
|
13
|
+
the `.json` with a `messageText` value.
|
|
14
|
+
|
|
15
|
+
## Target Types
|
|
16
|
+
|
|
17
|
+
| Target | Trigger event | Usage |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| `PromoteNotification` | Lifecycle promote action | Link in a lifecycle's "Send Notification" command |
|
|
20
|
+
| `AccountNotification` | User creation / password reset | Link in Tenant Settings → Notifications |
|
|
21
|
+
| `WorkflowNotification` | Workflow notification activity | Link in Workflow → Notification Activity |
|
|
22
|
+
| `WorkflowTaskNotification` | Workflow input/checklist task creation | Link in Workflow → Input/Checklist Task Activity |
|
|
23
|
+
|
|
24
|
+
## @Model Variables — PromoteNotification
|
|
25
|
+
|
|
26
|
+
Available when `target` is `PromoteNotification`. These reflect the promoted instance.
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
@Model.InstanceName — Display name of the instance
|
|
30
|
+
@Model.InstanceUrl — Deep link URL to the instance
|
|
31
|
+
@Model.InstanceId — GUID of the instance
|
|
32
|
+
@Model.InstanceTrackingId — Tracking / reference number
|
|
33
|
+
@Model.PreviousStateName — Lifecycle state before the promote
|
|
34
|
+
@Model.CurrentStateName — Lifecycle state after the promote
|
|
35
|
+
@Model.ModifiedDate — Date/time of the promote (UTC)
|
|
36
|
+
@Model.ModifiedBy — Name of the user who triggered it
|
|
37
|
+
@Model.CreatedBy — Name of the user who created the instance
|
|
38
|
+
@Model.Remarks — Remarks entered on the promote action
|
|
39
|
+
@Model.AttributeValues["<AttributeName>"] — Attribute accessor (see below)
|
|
40
|
+
@Model.TenantInfo.TenantName
|
|
41
|
+
@Model.TenantInfo.LogoUrl
|
|
42
|
+
@Model.TenantInfo.DateTimeFormat.DateFormat
|
|
43
|
+
@Model.TenantInfo.DateTimeFormat.TimeFormat
|
|
44
|
+
@Model.TenantInfo.DateTimeFormat.TimeZone
|
|
45
|
+
@Model.TenantInfo.Locale.Name
|
|
46
|
+
@Model.TenantInfo.Locale.DisplayName
|
|
47
|
+
@Model.ApplicationUrl
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## @Model Variables — AccountNotification
|
|
51
|
+
|
|
52
|
+
Available when `target` is `AccountNotification`. These reflect the Protrak user account.
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
@Model.UserName
|
|
56
|
+
@Model.UserFullName
|
|
57
|
+
@Model.UserEmail
|
|
58
|
+
@Model.UserPassword
|
|
59
|
+
@Model.CompanyName
|
|
60
|
+
@Model.CompanyEmail
|
|
61
|
+
@Model.CompanyDomain
|
|
62
|
+
@Model.CompanyLogoUrl
|
|
63
|
+
@Model.UserRoles[index]
|
|
64
|
+
@Model.UserAttributes["<AttributeName>"]
|
|
65
|
+
@Model.MFAVerificationCode
|
|
66
|
+
@Model.MFAVerificationCodeValidity
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## @Model Variables — WorkflowNotification
|
|
70
|
+
|
|
71
|
+
Available when `target` is `WorkflowNotification`. These reflect the instance attached to the workflow.
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
@Model.InstanceName
|
|
75
|
+
@Model.InstanceUrl
|
|
76
|
+
@Model.InstanceId
|
|
77
|
+
@Model.InstanceTrackingId
|
|
78
|
+
@Model.ApplicationUrl
|
|
79
|
+
@Model.StateName
|
|
80
|
+
@Model.ModifiedDate
|
|
81
|
+
@Model.ModifiedBy
|
|
82
|
+
@Model.CreatedBy
|
|
83
|
+
@Model.AttributeValues["<AttributeName>"]
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## @Model Variables — WorkflowTaskNotification
|
|
87
|
+
|
|
88
|
+
Available when `target` is `WorkflowTaskNotification`. These reflect the workflow task.
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
@Model.InstanceName
|
|
92
|
+
@Model.InstanceUrl
|
|
93
|
+
@Model.InstanceId
|
|
94
|
+
@Model.InstanceTrackingId
|
|
95
|
+
@Model.ApplicationUrl
|
|
96
|
+
@Model.StateName
|
|
97
|
+
@Model.ModifiedDate
|
|
98
|
+
@Model.ModifiedBy
|
|
99
|
+
@Model.CreatedBy
|
|
100
|
+
@Model.AttributeValues["<AttributeName>"]
|
|
101
|
+
@Model.WorkflowTask.Id
|
|
102
|
+
@Model.WorkflowTask.Name
|
|
103
|
+
@Model.WorkflowTask.Description
|
|
104
|
+
@Model.WorkflowTask.Comments
|
|
105
|
+
@Model.WorkflowTask.Type
|
|
106
|
+
@Model.WorkflowTask.WorkflowInstanceId
|
|
107
|
+
@Model.WorkflowTask.PausedWorkflowInstActivityId
|
|
108
|
+
@Model.WorkflowTask.TypeInstId
|
|
109
|
+
@Model.WorkflowTask.Status
|
|
110
|
+
@Model.WorkflowTask.AssignedUserId
|
|
111
|
+
@Model.WorkflowTask.AssignedRoleId
|
|
112
|
+
@Model.WorkflowTask.Created
|
|
113
|
+
@Model.WorkflowTask.Due
|
|
114
|
+
@Model.WorkflowTask.Completed
|
|
115
|
+
@Model.WorkflowTask.CompletedBy
|
|
116
|
+
@Model.WorkflowTask.ChecklistItems[index].Id
|
|
117
|
+
@Model.WorkflowTask.ChecklistItems[index].Name
|
|
118
|
+
@Model.WorkflowTask.ChecklistItems[index].IsMandatory
|
|
119
|
+
@Model.WorkflowTask.ChecklistItems[index].IsCompleted
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Attribute Value Accessors
|
|
123
|
+
|
|
124
|
+
The `@Model.AttributeValues["<AttributeName>"]` accessor returns an object with type-specific
|
|
125
|
+
properties. Access the correct property based on the attribute type:
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
.TextValue — Text attributes
|
|
129
|
+
.NumericValue — Numeric attributes
|
|
130
|
+
.DateValue — Date attributes (stored as UTC DateTime)
|
|
131
|
+
.ArrayValue[0] — Picklist (single) — string value
|
|
132
|
+
.UserValues[0].UserName — User attributes (first user)
|
|
133
|
+
.UserValues[0].UserEmail
|
|
134
|
+
.UserValues[0].UserId
|
|
135
|
+
.ReferenceValue[0].Name — Reference attributes (first linked instance)
|
|
136
|
+
.ReferenceValue[0].Id
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Razor Syntax Examples
|
|
140
|
+
|
|
141
|
+
### Simple substitution
|
|
142
|
+
```html
|
|
143
|
+
<p>Hi @Model.AttributeValues["Employee"].UserValues[0].UserName,</p>
|
|
144
|
+
<p>Your <a href='@Model.InstanceUrl'>@Model.InstanceName</a> has been approved.</p>
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Conditional content
|
|
148
|
+
```html
|
|
149
|
+
@if (Model.AttributeValues["Country"].ArrayValue[0] == "India")
|
|
150
|
+
{
|
|
151
|
+
<span>Tax rate: @Model.AttributeValues["GSTRate"].NumericValue%</span>
|
|
152
|
+
}
|
|
153
|
+
else
|
|
154
|
+
{
|
|
155
|
+
<span>No tax applicable.</span>
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Null-safe access
|
|
160
|
+
```html
|
|
161
|
+
@(Model.AttributeValues.ContainsKey("Title")
|
|
162
|
+
? Model.AttributeValues["Title"]?.TextValue ?? "N/A"
|
|
163
|
+
: "N/A")
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## File Format
|
|
167
|
+
|
|
168
|
+
### `<title>.json`
|
|
169
|
+
```json
|
|
170
|
+
{
|
|
171
|
+
"title": "Invoice Sent Body",
|
|
172
|
+
"target": "PromoteNotification",
|
|
173
|
+
"emailText": null,
|
|
174
|
+
"hashCode": "",
|
|
175
|
+
"messageText": null,
|
|
176
|
+
"templateState": "Published"
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
- `emailText` is always `null` in file-based templates — the email body lives in the `.html` file.
|
|
181
|
+
- `hashCode` is left empty; Protrak recomputes it on import (same pattern as `programHashCode`).
|
|
182
|
+
- `messageText` is the plain-text push notification content (Razor syntax supported).
|
|
183
|
+
- `templateState`: `"Published"` or `"InProgress"`.
|
|
184
|
+
|
|
185
|
+
### `<title>.html`
|
|
186
|
+
```html
|
|
187
|
+
<p>Hi @Model.AttributeValues["Manager"].UserValues[0].UserName,</p>
|
|
188
|
+
<p>Invoice <strong>@Model.InstanceName</strong> has been sent to the client.</p>
|
|
189
|
+
<p><a href='@Model.InstanceUrl'>View invoice</a></p>
|
|
190
|
+
<p><i>This is a system generated mail, please do not reply.</i></p>
|
|
191
|
+
```
|
package/package.json
CHANGED
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
# Aggregate Related Instances Pattern
|
|
3
|
-
|
|
4
|
-
## When to Use
|
|
5
|
-
Use this pattern whenever a parent instance needs a value computed from its related child instances. Common uses:
|
|
6
|
-
- Sum/total an attribute across related records (consumed effort, total cost, hours logged)
|
|
7
|
-
- Count related instances by state (open subtasks, overdue items)
|
|
8
|
-
- Roll up a max/min/latest date from related records
|
|
9
|
-
- Calculate completion percentages from related deliverables
|
|
10
|
-
|
|
11
|
-
This pattern is the canonical replacement for the **N+1 fan-out anti-pattern** (calling `GetInstanceAsync` once per related id just to read attributes — see "Anti-Pattern" below).
|
|
12
|
-
|
|
13
|
-
## Program Types
|
|
14
|
-
Applies to: `Scheduler`, `PostCreate`, `PromoteActionCommand`, `Common`
|
|
15
|
-
|
|
16
|
-
## Services Used
|
|
17
|
-
- `IInstanceService` — query parents, fetch related instances, write the aggregate back
|
|
18
|
-
- `IQueryBuilderService` — filter the parent set by state/attribute (optional)
|
|
19
|
-
|
|
20
|
-
## Key Idiom: `RelatedQueryBuilder.Select(...)`
|
|
21
|
-
|
|
22
|
-
The single most important rule for this pattern:
|
|
23
|
-
|
|
24
|
-
> **Always use `RelatedQueryBuilder.Select(...)` to fetch the attributes you need from related instances in the same call — do not call `GetInstanceAsync` per related id.**
|
|
25
|
-
|
|
26
|
-
`GetRelatedInstancesAsync` returns `RelatedInstance` objects that already carry the attributes you `.Select()`-ed. Calling `GetInstanceAsync` afterwards is an N+1 round-trip per parent and is strictly worse.
|
|
27
|
-
|
|
28
|
-
## Code Example
|
|
29
|
-
|
|
30
|
-
Scheduler that, for each Project in `Active` state, sums "consumed effort" across all related Allocations (weekdays between AllocationStartDate and AllocationEndDate, today if end is blank/future) and writes back to `ConsumedEffort` and `ConsumedEffortCalculatedOn` on the Project.
|
|
31
|
-
|
|
32
|
-
```csharp
|
|
33
|
-
using Prorigo.Protrak.API.Contracts;
|
|
34
|
-
using Prorigo.Protrak.API.Contracts.Builders;
|
|
35
|
-
using Prorigo.Protrak.API.Contracts.Extensions;
|
|
36
|
-
using System;
|
|
37
|
-
using System.Linq;
|
|
38
|
-
using System.Threading.Tasks;
|
|
39
|
-
|
|
40
|
-
namespace MyProject.Customization
|
|
41
|
-
{
|
|
42
|
-
public class CalculateProjectConsumedEffort : ISchedulerProgramAsync
|
|
43
|
-
{
|
|
44
|
-
public IInstanceService InstanceService { get; set; }
|
|
45
|
-
public IQueryBuilderService QueryBuilderService { get; set; }
|
|
46
|
-
|
|
47
|
-
public async Task RunAsync()
|
|
48
|
-
{
|
|
49
|
-
var projects = await InstanceService.GetInstancesAsync(new InstanceQuery
|
|
50
|
-
{
|
|
51
|
-
TypeName = "Project",
|
|
52
|
-
StateName = "Active",
|
|
53
|
-
Take = int.MaxValue,
|
|
54
|
-
});
|
|
55
|
-
if (projects?.Data == null) return;
|
|
56
|
-
|
|
57
|
-
var today = DateTime.UtcNow.Date;
|
|
58
|
-
|
|
59
|
-
foreach (var project in projects.Data)
|
|
60
|
-
{
|
|
61
|
-
// ✅ Fetch only the attributes we need from related Allocations,
|
|
62
|
-
// in a SINGLE call. Do NOT loop GetInstanceAsync per id.
|
|
63
|
-
var allocationQuery = RelatedQueryBuilder.Create()
|
|
64
|
-
.ForRelation("ProjectToAllocation", "Allocation", RelationDirection.From)
|
|
65
|
-
.Select("AllocationStartDate", "AllocationEndDate")
|
|
66
|
-
.TakeAll()
|
|
67
|
-
.Build();
|
|
68
|
-
|
|
69
|
-
var allocations = await InstanceService.GetRelatedInstancesAsync(
|
|
70
|
-
project.Id, allocationQuery);
|
|
71
|
-
|
|
72
|
-
double totalWeekdays = 0;
|
|
73
|
-
foreach (var allocation in allocations.SafeItems())
|
|
74
|
-
{
|
|
75
|
-
var start = allocation.GetDateAttributeValue("AllocationStartDate");
|
|
76
|
-
var end = allocation.GetDateAttributeValue("AllocationEndDate");
|
|
77
|
-
if (start == null) continue;
|
|
78
|
-
|
|
79
|
-
var effectiveEnd = (end == null || end.Value.Date > today)
|
|
80
|
-
? today
|
|
81
|
-
: end.Value.Date;
|
|
82
|
-
|
|
83
|
-
totalWeekdays += CountWeekdays(start.Value.Date, effectiveEnd);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
var update = InstanceBuilder.ForUpdate(project.Id, "Project")
|
|
87
|
-
.SetNumericAttributeValue("ConsumedEffort", totalWeekdays)
|
|
88
|
-
.SetDateAttributeValue("ConsumedEffortCalculatedOn", today)
|
|
89
|
-
.Build();
|
|
90
|
-
|
|
91
|
-
await InstanceService.UpdateInstanceAsync(update, null);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
private static int CountWeekdays(DateTime fromInclusive, DateTime toInclusive)
|
|
96
|
-
{
|
|
97
|
-
if (toInclusive < fromInclusive) return 0;
|
|
98
|
-
int count = 0;
|
|
99
|
-
for (var d = fromInclusive; d <= toInclusive; d = d.AddDays(1))
|
|
100
|
-
{
|
|
101
|
-
if (d.DayOfWeek != DayOfWeek.Saturday && d.DayOfWeek != DayOfWeek.Sunday)
|
|
102
|
-
count++;
|
|
103
|
-
}
|
|
104
|
-
return count;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
## Anti-Pattern (DO NOT DO THIS)
|
|
111
|
-
|
|
112
|
-
```csharp
|
|
113
|
-
// ❌ Wrong: N+1 round-trips. One DB hit per related allocation.
|
|
114
|
-
var allocationsRef = await InstanceService.GetRelatedInstancesAsync(
|
|
115
|
-
project.Id, RelatedQueryBuilder.Create()
|
|
116
|
-
.ForRelation("ProjectToAllocation", "Allocation", RelationDirection.From)
|
|
117
|
-
.TakeAll().Build());
|
|
118
|
-
|
|
119
|
-
foreach (var ref in allocationsRef.SafeItems())
|
|
120
|
-
{
|
|
121
|
-
var full = await InstanceService.GetInstanceAsync(
|
|
122
|
-
ref.Id, new[] { "AllocationStartDate", "AllocationEndDate" }); // ← extra call per id
|
|
123
|
-
var start = full.GetDateAttributeValue("AllocationStartDate");
|
|
124
|
-
// ...
|
|
125
|
-
}
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
The fix is to add `.Select("AllocationStartDate", "AllocationEndDate")` to the builder and read the attributes off the `RelatedInstance` directly. `RelatedInstance` exposes the same `GetTextAttributeValue` / `GetDateAttributeValue` / `GetNumericAttributeValue` accessors as `Instance` — see [Instance Accessor Methods](./instance-accessor-methods.md#on-relatedinstance).
|
|
129
|
-
|
|
130
|
-
## Key Rules
|
|
131
|
-
|
|
132
|
-
- Always pre-select the attributes you need with `.Select(name1, name2, ...)`. Omitting `.Select()` returns instances without their attribute values populated; adding `GetInstanceAsync` calls afterwards is the N+1 anti-pattern.
|
|
133
|
-
- Use `.TakeAll()` (or `Skip`/`Take` for pagination) — the default page size truncates large parents.
|
|
134
|
-
- Use `RelatedInstance` accessors directly (`GetDateAttributeValue`, etc.) — same syntax as `Instance`.
|
|
135
|
-
- Iterate with `.SafeItems()` to avoid manual null checks.
|
|
136
|
-
- Write the rolled-up value back with `InstanceBuilder.ForUpdate(...)` + `UpdateInstanceAsync`. Do not mutate the parent in-place inside a Scheduler — Scheduler programs receive no incoming Instance.
|
|
137
|
-
- For schedulers, prefer `Take = int.MaxValue` on the parent query to ensure all parents are processed; for *very* large datasets, paginate explicitly.
|
|
138
|
-
|
|
139
|
-
## Related Patterns
|
|
140
|
-
- [Query and Filter](./query-filter-pattern.md) — filtering the parent set
|