Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
f22adbb
test(features): pin repeater entries kept one at a time
SirLouen Sep 26, 2026
b287378
feat(fields): reserve id as a sub field name
SirLouen Sep 26, 2026
20d18a3
docs(fields): say a sub field cannot be named id
SirLouen Sep 26, 2026
93e6073
feat(fields): give every stored repeater entry an id
SirLouen Sep 26, 2026
1fa749d
feat(fields): check one repeater entry
SirLouen Sep 26, 2026
368b997
feat(fields): keep a sub field key clear of id on the Fields screen
SirLouen Sep 26, 2026
7bb8568
docs(fields): say a sub field labelled ID becomes id2
SirLouen Sep 26, 2026
ae8de24
chore(deps): move to godmin 0.11.0
SirLouen Sep 26, 2026
8696953
feat(sdk): export the log list
SirLouen Sep 26, 2026
2961454
refactor(fields): move the cell inputs into their own module
SirLouen Sep 26, 2026
4e6cf63
test(postgres): walk each function's own statement consts
SirLouen Sep 27, 2026
d4b2523
test(postgres): hold each table a statement touches to the caller's t…
SirLouen Sep 27, 2026
db31f37
test(postgres): walk the fields store and seed over the core tables t…
SirLouen Sep 27, 2026
a55c9b8
feat(fields): store repeater entries one statement at a time
SirLouen Sep 27, 2026
bfc39e3
feat(fields): cap the entries one repeater holds
SirLouen Sep 27, 2026
f31b956
docs(configuration): describe the repeater entries cap
SirLouen Sep 27, 2026
800d0a5
feat(fields): add, update and delete repeater entries over the graph
SirLouen Sep 27, 2026
fd42a40
chore(graph): regenerate the schema with repeater entry mutations
SirLouen Sep 27, 2026
662e015
test(fields): round trip repeater entries over the graph
SirLouen Sep 27, 2026
0103e6d
docs(reference): describe the repeater entry mutations
SirLouen Sep 27, 2026
9669f76
test(features): run the repeater entry scenarios the graph now serves
SirLouen Sep 27, 2026
765face
feat(fields): seed the demo history as entries in one statement
SirLouen Sep 27, 2026
16a3590
test(alphone): expect the three demo history entries from the seed bi…
SirLouen Sep 27, 2026
beecdae
test(fields): share the contact panel test harness
SirLouen Sep 27, 2026
394617e
refactor(fields): name the catalogue and values operations beside the…
SirLouen Sep 27, 2026
7939fe5
test(e2e): keep a contact history through the add form
SirLouen Sep 27, 2026
1ed6bd6
feat(fields): add the operations that add and remove a repeater entry
SirLouen Sep 27, 2026
095bd1f
feat(fields): show repeater entries as a list with one add form
SirLouen Sep 27, 2026
639ffbd
feat(fields): refuse a repeater written through writeContactFields
SirLouen Sep 27, 2026
fd83a39
test(features): retire the whole-list repeater scenarios and run the …
SirLouen Sep 27, 2026
cfee30d
feat(fields): add the operation that updates a repeater entry
SirLouen Sep 27, 2026
35d4865
feat(fields): edit a repeater entry in place
SirLouen Sep 27, 2026
d12c6d9
test(e2e): edit a contact history entry in place
SirLouen Sep 27, 2026
72c0691
test(e2e): keep a contact history one entry at a time
SirLouen Sep 27, 2026
9ceb619
docs(fields): document repeater entries kept one at a time
SirLouen Sep 27, 2026
3ce4b88
fix(fields): refuse a repeater first when writing contact fields
SirLouen Sep 27, 2026
2e4e35f
fix(fields): give stored entries their ids on Postgres before 18
SirLouen Sep 27, 2026
970cee4
fix(fields): lock an entry's cells while it is stored
SirLouen Sep 27, 2026
99cf480
fix(fields): clear an entry failure when the next action starts
SirLouen Sep 27, 2026
911dfb9
fix(fields): name an entry by its first line of words
SirLouen Sep 27, 2026
9add041
test(fields): tighten the entry tests and fix their clock
SirLouen Sep 27, 2026
920e653
test(features): keep the remember docblock to what it does
SirLouen Sep 27, 2026
b34e216
fix(fields): name an entry by its day and its first line
SirLouen Sep 27, 2026
b1faef1
test(e2e): find contact history entries by day and first line
SirLouen Sep 27, 2026
24680ae
chore(deps): update gottext to 0.5.1
SirLouen Sep 27, 2026
67b931f
refactor(fields): show entry days through formatDate
SirLouen Sep 27, 2026
8b26f94
fix(fields): seed the newest demo history the entry cap holds
SirLouen Sep 27, 2026
cd2bb80
test(e2e): wait for a graph answer by its operation name
SirLouen Sep 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ ALPHONE_DATABASE_URL=postgres://postgres:alphone@localhost:5433/postgres?sslmode
# How long a server process keeps a tenant's fields before reading them again, 1m when unset.
# ALPHONE_TENANTS_REFRESH=1m

# The most entries one repeater field holds on a contact, 500 when unset.
# ALPHONE_FIELDS_ENTRIES_MAX=500

# Any non-empty value serves the interactive GraphiQL page on GET /api/graphql.
# ALPHONE_DEV_GRAPHIQL=1

Expand Down
4 changes: 2 additions & 2 deletions cmd/alphone/main_exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -256,8 +256,8 @@ func TestMainBinarySeedFillsTheDemoHistoryOnTheFirstRun(t *testing.T) {
if maria["birthDate"] != "1990-04-17" {
t.Errorf("birthDate = %#v, want 1990-04-17", maria["birthDate"])
}
if rows, ok := maria["history"].([]any); !ok || len(rows) != 2 {
t.Errorf("history = %#v, want the two demo entries from a single seed run", maria["history"])
if rows, ok := maria["history"].([]any); !ok || len(rows) != 3 {
t.Errorf("history = %#v, want the three demo entries from a single seed run", maria["history"])
}
}

Expand Down
126 changes: 89 additions & 37 deletions docs/src/content/docs/guides/fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@ filled in.
## Fill a field in

Open a contact. The **Fields** section sits beside the tasks, or under them
on a narrow screen, with one input per field you created. Type, then press
**Save fields**.
on a narrow screen. Type into a field, then press **Save fields**.

A Long text field is a box several lines tall. Press Enter to start a new
line. The text keeps its line breaks when you save.
Expand All @@ -60,24 +59,31 @@ Long text.
A sub field can be any kind except Repeater, so a repeater never holds another
repeater. You do not type a name for a sub field. AlphOne makes one from its
label, so `Follow-up comment` becomes `followUpComment`. Two sub fields with
the same label get two names, such as `note` and `note2`.
the same label get two names, such as `note` and `note2`. A sub field labelled
`ID` becomes `id2`, because each entry keeps its own id under `id`.

The sub fields cannot be changed once the repeater exists. The reason is the
same as for the kind: old entries would no longer fit.

On a contact, a repeater lists its entries one under the other.
On a contact, each repeater shows under its own heading. A form to add an
entry sits on top. The entries follow, the last one added first. Each date in
the form starts on today's date.

- Press **Add an entry to History**, with your repeater's label in place of
History, to add an entry at the end.
- Use the arrows beside an entry to move it up or down.
- Press **Remove entry** to take one away.
- Fill in the form and press **Add an entry to History**, with your
repeater's label in place of History. The button stays off until you fill
in a part yourself. Spaces alone do not count, and neither does the date
the form starts with.
- Press **Edit entry** to change an entry in place, then **Save entry** or
**Cancel**. **Save entry** stays off while every part is blank.
- Press **Remove entry**, and the row asks **Remove this entry?** Press
**Remove** to confirm or **Keep** to leave it.

Then press **Save fields**. Saving stores the whole list, in the order you
see it. An entry you leave completely empty is dropped when you save.
Each add, save and removal is stored at once. **Save fields** saves only the
other fields, and it is hidden when there are none. Each change touches only
its own entry. If two people edit the same entry, the last save wins.

Saving replaces the list that was stored before. If two people change the
same list on two open pages, the second save wins and the first person's
changes are lost.
A list holds at most 500 entries by default. See
[Configuration](/self-hosting/configuration/#fields-plugin) to change the cap.

## Fill a field from a spreadsheet

Expand Down Expand Up @@ -139,7 +145,7 @@ API tools and AI agents discover it on their own.
A field belongs to the workspace that created it. Callers in another workspace
never see it, not in the API and not in introspection.

Writing values goes through one mutation:
Writing values goes through `writeContactFields`:

```graphql
mutation {
Expand All @@ -157,28 +163,9 @@ is cleared.
A `Number` field holds a whole number between -2147483648 and 2147483647.
Anything outside that is refused, because the API answers it as an `Int`.

A repeater reads and writes as a list of entries. Each entry is an object
keyed by sub field name, and the API types the whole list as `JSON`:

```graphql
mutation {
writeContactFields(
contactId: "0198c000-0000-7000-8000-000000000401"
values: {
history: [
{ date: "2026-09-01", comment: "First call about the yearly plan." }
{ date: "2026-09-10", comment: "Sent the offer." }
]
}
)
}
```

Sending a repeater replaces its whole list. An empty list or `null` clears
it. Every cell is checked against its sub field's kind, and a refusal names
the cell by its place in the list, counting from 0, such as
`history[0].date expects DATE`. A key that no sub field holds is refused the
same way, such as `history[1].mood`.
`writeContactFields` refuses any value for a repeater, even `null`, with the
reason `field_repeater_entries_only`, and stores nothing from that request. A
repeater takes its entries one at a time, as shown below.

To define a repeater from the API, pass its sub fields to `defineField`:

Expand All @@ -202,7 +189,72 @@ mutation {
```

The API does not make sub field names for you. Send a camelCase name for each
one, unique inside the repeater.
one, unique inside the repeater. `id` is refused, because each entry keeps its
own id under that name.

A repeater reads as a list of entries typed `JSON`, the last one added first.
Each entry is an object keyed by sub field name, with the `id` AlphOne gave
it. A repeater with no entries reads `null`. Ask for `history` the same way as
`birthDate`, and the answer looks like this:

```json
{
"data": {
"contact": {
"history": [
{
"id": "0198c000-0000-7000-8000-000000000501",
"date": "2026-09-10",
"comment": "Sent the offer."
}
]
}
}
}
```

`addContactFieldEntry` puts one entry at the top of the list:

```graphql
mutation {
addContactFieldEntry(
contactId: "0198c000-0000-7000-8000-000000000401"
field: "history"
entry: { date: "2026-09-10", comment: "Sent the offer." }
)
}
```

It answers the stored entry with its new `id`. Every cell is checked against
its sub field's kind, and a refusal names the cell, such as
`history.date expects DATE`. A key that no sub field holds is refused and
named too, such as `history.mood`. So is `id`. An entry whose cells are all
blank is refused with `field_entry_empty`. An add to a full list is refused
with `field_entries_full`, and its `meta.max` names the cap.

`updateContactFieldEntry` changes the entry that `entryId` names:

```graphql
mutation {
updateContactFieldEntry(
contactId: "0198c000-0000-7000-8000-000000000401"
field: "history"
entryId: "0198c000-0000-7000-8000-000000000501"
entry: { date: "2026-09-10", comment: "Sent the offer by email." }
)
}
```

The entry you send replaces all its cells, so a cell you leave out is dropped.
The cells get the same checks as an add, but you may send the entry's own `id`
back. The entry keeps its id and its place, and the answer is the entry as
stored.

`deleteContactFieldEntry` takes the same `contactId`, `field` and `entryId`,
and answers `true`. An update or a removal naming an id the list does not
hold is refused with `field_entry_not_found`. See
[GraphQL API](/reference/graphql-api/#reasons) for where a refusal carries its
reason and `meta`.

## What stays fixed

Expand Down
11 changes: 9 additions & 2 deletions docs/src/content/docs/reference/graphql-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ Every error carries a `code` in its `extensions`.
| `UNAUTHORIZED` | The caller does not reach the field. `scope required` means the token lacks the scope `scope` names, `admin required` means the account's role holds no capability the field needs |
| `VALIDATION` | The input was refused. `message` names the field or rule |
| `NOT_FOUND` | The id names nothing |
| `CONFLICT` | An identity is already claimed. `ownerContactId` names the owner |
| `CONFLICT` | The write clashes with what is stored, such as an identity another contact holds or a full list. For an identity, `ownerContactId` names the owner |
| `RATE_LIMITED` | Too many attempts. `retryAfter` is in seconds |
| `COMPLEXITY_LIMIT_EXCEEDED` | The query asks for too much, see limits below |
| `INTERNAL` | AlphOne failed. The message is deliberately bare |
Expand Down Expand Up @@ -419,12 +419,18 @@ The stock plugins add their own:
| `field_sub_field_nested` | | a sub field cannot be a repeater |
| `field_sub_field_name_invalid` | | a sub field name is camelCase |
| `field_sub_field_name_taken` | | two sub fields of one repeater share a name |
| `field_sub_field_name_reserved` | | a sub field is named `id`, which holds each entry's own id |
| `field_name_taken` | | another definition holds the name |
| `field_kind_locked` | | an archived definition pins the kind and sub fields |
| `field_not_found` | | the id names no live definition |
| `field_unknown` | | no live definition holds the name |
| `field_unknown` | | no live definition or sub field holds the name |
| `value_kind_mismatch` | | the value does not match the declared kind |
| `values_not_an_object` | | values arrive as an object of names |
| `field_not_a_repeater` | | the field keeps no list of entries |
| `field_entry_empty` | | every cell of the entry is blank |
| `field_entry_not_found` | | the id names no entry in that field of the contact |
| `field_entries_full` | `max` | the list already holds the [most entries](/self-hosting/configuration/#fields-plugin) it may |
| `field_repeater_entries_only` | | a repeater takes its entries one at a time, never through `writeContactFields` |
| `message_content_required` | | a message needs text |
| `conversation_not_found` | | the id names no conversation |
| `upstream_failed` | | the messaging platform did not accept |
Expand Down Expand Up @@ -514,6 +520,7 @@ cannot drift. Point a client at the endpoint, or read
| Contacts | `contacts`, `contact` | `createContact`, `renameContact`, `addContactIdentity`, `deleteContactIdentity` |
| Tasks | `tasks`, `task` | `createTask`, `updateTask` |
| Webhooks | `webhooks` | `createWebhook`, `deleteWebhook` |
| Fields | `fields`, `Contact.field` | `defineField`, `archiveField`, `writeContactFields`, `addContactFieldEntry`, `updateContactFieldEntry`, `deleteContactFieldEntry` |
| Imports | `imports`, `importJob`, `importFields` | `importUpload`, `importSetMapping`, `importCommit` |
| WhatsApp | `whatsAppConversations`, `whatsAppConversation` | `whatsAppSendMessage` |
| Version | `version` | |
Expand Down
6 changes: 6 additions & 0 deletions docs/src/content/docs/self-hosting/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ database is all an upgrade takes.
| `ALPHONE_TRUSTED_PROXIES` | no | unset | Comma-separated CIDR ranges allowed to set `X-Forwarded-For`, e.g. `172.18.0.0/16`. Only addresses in these ranges are trusted when the login rate limiter resolves the client IP. Unset, the direct peer address is used. **Set this whenever AlphOne runs behind a reverse proxy**, or all visitors share one rate-limit bucket. Each entry must be CIDR notation. A bare IP is rejected at startup. |
| `ALPHONE_DEV_GRAPHIQL` | no | unset | Any non-empty value serves the interactive GraphiQL page on `GET /api/graphql`. Development only. |

## Fields plugin

| Variable | Purpose |
| --- | --- |
| `ALPHONE_FIELDS_ENTRIES_MAX` | The most entries one repeater field holds on a contact. Defaults to 500. An add past the cap is refused, and AlphOne will not start if the value is not a whole number between 1 and 2147483647. |

## WhatsApp plugin

All optional. Without them the plugin runs inert: screens exist, but no
Expand Down
4 changes: 2 additions & 2 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
"@alphone/plugin-fields": "workspace:*",
"@alphone/plugin-importer": "workspace:*",
"@alphone/plugin-whatsapp": "workspace:*",
"@gopherium/godmin": "0.10.0",
"@gopherium/gottext": "0.5.0",
"@gopherium/godmin": "0.11.0",
"@gopherium/gottext": "0.5.1",
"@gopherium/react-auth": "0.9.0",
"@tanstack/react-query": "^5.102.8",
"@tanstack/react-router": "^1.170.36",
Expand Down
Loading
Loading