Compare commits
2 Commits
a06f21a5c2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ed2f25ba98 | |||
| 228e2e99f7 |
@@ -0,0 +1,216 @@
|
|||||||
|
# Backend Architecture
|
||||||
|
|
||||||
|
## Role of the backend
|
||||||
|
|
||||||
|
The backend is intentionally a **dumb encrypted sync target**. It does not understand the domain model, object types, or the tree structure. All business logic lives in the frontend. The backend only stores and retrieves opaque encrypted JSON blobs on behalf of an authenticated user.
|
||||||
|
|
||||||
|
This design is chosen to support end-to-end encryption (E2EE) and local-first sync in the future. The server never needs to decrypt object data.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
### `users`
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
| ------------ | ------------ | ---------------------------------------- |
|
||||||
|
| `userId` | VARCHAR(255) | Primary key. UUID or opaque identifier for the user. |
|
||||||
|
| `name` | VARCHAR(255) | Display name for the user. |
|
||||||
|
| `rootObjectId` | VARCHAR(255) | Points to the user's root object row. |
|
||||||
|
|
||||||
|
### `objects`
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
| ------------ | ------------ | -------------------------------------------------- |
|
||||||
|
| `objectId` | VARCHAR(255) | Primary key. UUID identifying the object. |
|
||||||
|
| `userId` | VARCHAR(255) | Owner of the object. |
|
||||||
|
| `objectData` | TEXT | Encrypted JSON blob (currently stored as plaintext during PoC). |
|
||||||
|
|
||||||
|
## API contract
|
||||||
|
|
||||||
|
### Users
|
||||||
|
|
||||||
|
#### `POST /users`
|
||||||
|
|
||||||
|
Create a new user and an empty root object.
|
||||||
|
|
||||||
|
**Request body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userId": "string",
|
||||||
|
"name": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Routes use plural REST conventions (`/users`, `/objects`).
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "created",
|
||||||
|
"rootObjectId": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `GET /users/:id`
|
||||||
|
|
||||||
|
Return the user profile **and all associated objects**.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userId": "string",
|
||||||
|
"name": "string",
|
||||||
|
"rootObjectId": "string",
|
||||||
|
"objects": [
|
||||||
|
{
|
||||||
|
"objectId": "string",
|
||||||
|
"objectData": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`objects` includes the root object and every object owned by the user. The client uses this to rebuild its local state in one request.
|
||||||
|
|
||||||
|
#### `PUT /users/:id`
|
||||||
|
|
||||||
|
Update the user's display name.
|
||||||
|
|
||||||
|
**Request body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "updated"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: `userId` is immutable. This endpoint only updates the display `name`.
|
||||||
|
|
||||||
|
#### `DELETE /users/:id`
|
||||||
|
|
||||||
|
Delete the user and all associated objects.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "deleted"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Objects
|
||||||
|
|
||||||
|
#### `POST /objects`
|
||||||
|
|
||||||
|
Create a new object.
|
||||||
|
|
||||||
|
**Request body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"objectId": "string",
|
||||||
|
"userId": "string",
|
||||||
|
"objectData": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`objectId` is currently supplied by the client to support offline creation. In the future this may be server-generated as part of a more robust user creation flow.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "created",
|
||||||
|
"objectId": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `GET /objects/:id`
|
||||||
|
|
||||||
|
Fetch a single object by ID.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"objectId": "string",
|
||||||
|
"userId": "string",
|
||||||
|
"objectData": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `PUT /objects/:id`
|
||||||
|
|
||||||
|
Update an object's encrypted data.
|
||||||
|
|
||||||
|
**Request body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"objectData": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "updated"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The client is responsible for updating parent `childIds` arrays when objects are moved or re-parented.
|
||||||
|
|
||||||
|
#### `DELETE /objects/:id`
|
||||||
|
|
||||||
|
Delete a single object.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "deleted"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The server does not cascade deletions because it cannot decrypt `objectData` to know the object's children. The client must walk the tree and delete children explicitly.
|
||||||
|
|
||||||
|
## Design decisions
|
||||||
|
|
||||||
|
- **Opaque object data**: The server stores `objectData` as an opaque string so it can remain encrypted. It never parses or validates the JSON shape.
|
||||||
|
- **Client-owned tree logic**: Parent/child relationships are maintained inside the encrypted blobs by the frontend. The backend has no concept of roots, projects, tasks, or notes.
|
||||||
|
- **Bulk user fetch**: `GET /users/:id` returns the entire object graph so the frontend can reconstruct state in one request instead of walking the tree with N+1 fetches.
|
||||||
|
- **Plaintext PoC**: Encryption is planned but not implemented. `objectData` is currently plaintext JSON during early development.
|
||||||
|
|
||||||
|
## Known gaps and roadmap
|
||||||
|
|
||||||
|
| Priority | Item | Notes |
|
||||||
|
| -------- | ---- | ----- |
|
||||||
|
| High | Authentication & authorization | Every endpoint must verify the caller owns the requested user/object. Currently absent for PoC simplicity. |
|
||||||
|
| High | Ownership checks | `GET /objects/:id`, `PUT /objects/:id`, and `DELETE /objects/:id` must reject access to objects owned by another user. |
|
||||||
|
| Medium | Object versioning / `updatedAt` | Needed for safe multi-device sync and conflict detection. |
|
||||||
|
| Medium | Multi-device conflict resolution | Last-write-wins is acceptable for PoC; eventually needs a real strategy (server wins, CRDTs, manual merge, etc.). |
|
||||||
|
| Medium | Server-generated object IDs | Currently client-generated to support offline creation; may move to server-generated with auth flow. |
|
||||||
|
| Low | Orphan garbage collection | Server cannot safely clean up orphans because it cannot decrypt blobs. Client-side `cleanupOrphans` on startup is planned. |
|
||||||
|
| Low | Display name updates | `PUT /users/:id` updates `name`; `userId` remains immutable. |
|
||||||
|
|
||||||
|
Routes use plural REST conventions (`/users`, `/objects`) consistent with the implementation.
|
||||||
|
|
||||||
|
## Why not a single giant blob?
|
||||||
|
|
||||||
|
An alternative considered was storing the user's entire state as one encrypted JSON blob. Rejected because:
|
||||||
|
|
||||||
|
- Every small edit would require re-uploading the entire state.
|
||||||
|
- Avoiding that requires a diff-chain format (operation logs, CRDTs, Merkle trees), which is significantly more complex than per-object blobs.
|
||||||
|
|
||||||
|
Per-object blobs strike a balance: the server stays dumb, edits are small, and the sync protocol remains simple.
|
||||||
+1
-164
@@ -81,6 +81,7 @@ func createTables(db *sql.DB) error {
|
|||||||
"users": `
|
"users": `
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
userId VARCHAR(255) PRIMARY KEY,
|
userId VARCHAR(255) PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
rootObjectId VARCHAR(255) NOT NULL
|
rootObjectId VARCHAR(255) NOT NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`,
|
`,
|
||||||
@@ -109,167 +110,3 @@ func createTables(db *sql.DB) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// User represents a row in the users table.
|
|
||||||
type User struct {
|
|
||||||
UserID string
|
|
||||||
RootObjectID string
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateUser inserts a new user into the users table.
|
|
||||||
func CreateUser(userID, rootObjectID string) error {
|
|
||||||
_, err := DB.Exec(
|
|
||||||
"INSERT INTO users (userId, rootObjectId) VALUES (?, ?)",
|
|
||||||
userID, rootObjectID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create user: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUser retrieves a user by ID.
|
|
||||||
func GetUser(userID string) (User, error) {
|
|
||||||
var user User
|
|
||||||
err := DB.QueryRow(
|
|
||||||
"SELECT userId, rootObjectId FROM users WHERE userId = ?",
|
|
||||||
userID,
|
|
||||||
).Scan(&user.UserID, &user.RootObjectID)
|
|
||||||
if err != nil {
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return User{}, fmt.Errorf("user not found")
|
|
||||||
}
|
|
||||||
return User{}, fmt.Errorf("failed to get user: %w", err)
|
|
||||||
}
|
|
||||||
return user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateUser updates the rootObjectId for a user.
|
|
||||||
func UpdateUser(userID, rootObjectID string) error {
|
|
||||||
result, err := DB.Exec(
|
|
||||||
"UPDATE users SET rootObjectId = ? WHERE userId = ?",
|
|
||||||
rootObjectID, userID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to update user: %w", err)
|
|
||||||
}
|
|
||||||
rows, err := result.RowsAffected()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to check update result: %w", err)
|
|
||||||
}
|
|
||||||
if rows == 0 {
|
|
||||||
return fmt.Errorf("user not found")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteUser removes a user by ID.
|
|
||||||
func DeleteUser(userID string) error {
|
|
||||||
result, err := DB.Exec("DELETE FROM users WHERE userId = ?", userID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to delete user: %w", err)
|
|
||||||
}
|
|
||||||
rows, err := result.RowsAffected()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to check delete result: %w", err)
|
|
||||||
}
|
|
||||||
if rows == 0 {
|
|
||||||
return fmt.Errorf("user not found")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Object represents a row in the objects table.
|
|
||||||
type Object struct {
|
|
||||||
ObjectID string
|
|
||||||
UserID string
|
|
||||||
ObjectData string
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateObject inserts a new object into the objects table.
|
|
||||||
func CreateObject(objectID, userID, objectData string) error {
|
|
||||||
_, err := DB.Exec(
|
|
||||||
"INSERT INTO objects (objectId, userId, objectData) VALUES (?, ?, ?)",
|
|
||||||
objectID, userID, objectData,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create object: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetObject retrieves an object by ID.
|
|
||||||
func GetObject(objectID string) (Object, error) {
|
|
||||||
var obj Object
|
|
||||||
err := DB.QueryRow(
|
|
||||||
"SELECT objectId, userId, objectData FROM objects WHERE objectId = ?",
|
|
||||||
objectID,
|
|
||||||
).Scan(&obj.ObjectID, &obj.UserID, &obj.ObjectData)
|
|
||||||
if err != nil {
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return Object{}, fmt.Errorf("object not found")
|
|
||||||
}
|
|
||||||
return Object{}, fmt.Errorf("failed to get object: %w", err)
|
|
||||||
}
|
|
||||||
return obj, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetObjectsByUser retrieves all objects belonging to a user.
|
|
||||||
func GetObjectsByUser(userID string) ([]Object, error) {
|
|
||||||
rows, err := DB.Query(
|
|
||||||
"SELECT objectId, userId, objectData FROM objects WHERE userId = ?",
|
|
||||||
userID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to query objects: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var objects []Object
|
|
||||||
for rows.Next() {
|
|
||||||
var obj Object
|
|
||||||
if err := rows.Scan(&obj.ObjectID, &obj.UserID, &obj.ObjectData); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to scan object: %w", err)
|
|
||||||
}
|
|
||||||
objects = append(objects, obj)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to iterate objects: %w", err)
|
|
||||||
}
|
|
||||||
return objects, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateObject updates the objectData for an object.
|
|
||||||
func UpdateObject(objectID, objectData string) error {
|
|
||||||
result, err := DB.Exec(
|
|
||||||
"UPDATE objects SET objectData = ? WHERE objectId = ?",
|
|
||||||
objectData, objectID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to update object: %w", err)
|
|
||||||
}
|
|
||||||
rows, err := result.RowsAffected()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to check update result: %w", err)
|
|
||||||
}
|
|
||||||
if rows == 0 {
|
|
||||||
return fmt.Errorf("object not found")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteObject removes an object by ID.
|
|
||||||
func DeleteObject(objectID string) error {
|
|
||||||
result, err := DB.Exec("DELETE FROM objects WHERE objectId = ?", objectID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to delete object: %w", err)
|
|
||||||
}
|
|
||||||
rows, err := result.RowsAffected()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to check delete result: %w", err)
|
|
||||||
}
|
|
||||||
if rows == 0 {
|
|
||||||
return fmt.Errorf("object not found")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ require (
|
|||||||
github.com/go-playground/validator/v10 v10.30.3 // indirect
|
github.com/go-playground/validator/v10 v10.30.3 // indirect
|
||||||
github.com/goccy/go-json v0.10.6 // indirect
|
github.com/goccy/go-json v0.10.6 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
|
|||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||||
|
|||||||
-162
@@ -62,165 +62,3 @@ func main() {
|
|||||||
|
|
||||||
router.Run() // listens on 0.0.0.0:8080 by default
|
router.Run() // listens on 0.0.0.0:8080 by default
|
||||||
}
|
}
|
||||||
|
|
||||||
// createUserRequest is the expected body for POST /users.
|
|
||||||
type createUserRequest struct {
|
|
||||||
UserID string `json:"userId" binding:"required"`
|
|
||||||
RootObjectID string `json:"rootObjectId" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func createUserHandler(c *gin.Context) {
|
|
||||||
var req createUserRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := CreateUser(req.UserID, req.RootObjectID); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{"status": "created"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func getUserHandler(c *gin.Context) {
|
|
||||||
id := c.Param("id")
|
|
||||||
user, err := GetUser(id)
|
|
||||||
if err != nil {
|
|
||||||
if err.Error() == "user not found" {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateUserRequest is the expected body for PUT /users/:id.
|
|
||||||
type updateUserRequest struct {
|
|
||||||
RootObjectID string `json:"rootObjectId" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUserHandler(c *gin.Context) {
|
|
||||||
id := c.Param("id")
|
|
||||||
var req updateUserRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := UpdateUser(id, req.RootObjectID); err != nil {
|
|
||||||
if err.Error() == "user not found" {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteUserHandler(c *gin.Context) {
|
|
||||||
id := c.Param("id")
|
|
||||||
if err := DeleteUser(id); err != nil {
|
|
||||||
if err.Error() == "user not found" {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// createObjectRequest is the expected body for POST /objects.
|
|
||||||
type createObjectRequest struct {
|
|
||||||
ObjectID string `json:"objectId" binding:"required"`
|
|
||||||
UserID string `json:"userId" binding:"required"`
|
|
||||||
ObjectData string `json:"objectData" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func createObjectHandler(c *gin.Context) {
|
|
||||||
var req createObjectRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := CreateObject(req.ObjectID, req.UserID, req.ObjectData); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{"status": "created"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func getObjectHandler(c *gin.Context) {
|
|
||||||
id := c.Param("id")
|
|
||||||
obj, err := GetObject(id)
|
|
||||||
if err != nil {
|
|
||||||
if err.Error() == "object not found" {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, obj)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getObjectsByUserHandler(c *gin.Context) {
|
|
||||||
id := c.Param("id")
|
|
||||||
objects, err := GetObjectsByUser(id)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, objects)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateObjectRequest is the expected body for PUT /objects/:id.
|
|
||||||
type updateObjectRequest struct {
|
|
||||||
ObjectData string `json:"objectData" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateObjectHandler(c *gin.Context) {
|
|
||||||
id := c.Param("id")
|
|
||||||
var req updateObjectRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := UpdateObject(id, req.ObjectData); err != nil {
|
|
||||||
if err.Error() == "object not found" {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteObjectHandler(c *gin.Context) {
|
|
||||||
id := c.Param("id")
|
|
||||||
if err := DeleteObject(id); err != nil {
|
|
||||||
if err.Error() == "object not found" {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Object represents a row in the objects table.
|
||||||
|
type Object struct {
|
||||||
|
ObjectID string
|
||||||
|
UserID string
|
||||||
|
ObjectData string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateObject inserts a new object into the objects table.
|
||||||
|
func CreateObject(objectID, userID, objectData string) error {
|
||||||
|
_, err := DB.Exec(
|
||||||
|
"INSERT INTO objects (objectId, userId, objectData) VALUES (?, ?, ?)",
|
||||||
|
objectID, userID, objectData,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create object: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetObject retrieves an object by ID.
|
||||||
|
func GetObject(objectID string) (Object, error) {
|
||||||
|
var obj Object
|
||||||
|
err := DB.QueryRow(
|
||||||
|
"SELECT objectId, userId, objectData FROM objects WHERE objectId = ?",
|
||||||
|
objectID,
|
||||||
|
).Scan(&obj.ObjectID, &obj.UserID, &obj.ObjectData)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return Object{}, fmt.Errorf("object not found")
|
||||||
|
}
|
||||||
|
return Object{}, fmt.Errorf("failed to get object: %w", err)
|
||||||
|
}
|
||||||
|
return obj, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetObjectsByUser retrieves all objects belonging to a user.
|
||||||
|
func GetObjectsByUser(userID string) ([]Object, error) {
|
||||||
|
rows, err := DB.Query(
|
||||||
|
"SELECT objectId, userId, objectData FROM objects WHERE userId = ?",
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query objects: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var objects []Object
|
||||||
|
for rows.Next() {
|
||||||
|
var obj Object
|
||||||
|
if err := rows.Scan(&obj.ObjectID, &obj.UserID, &obj.ObjectData); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan object: %w", err)
|
||||||
|
}
|
||||||
|
objects = append(objects, obj)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to iterate objects: %w", err)
|
||||||
|
}
|
||||||
|
return objects, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateObject updates the objectData for an object.
|
||||||
|
func UpdateObject(objectID, objectData string) error {
|
||||||
|
result, err := DB.Exec(
|
||||||
|
"UPDATE objects SET objectData = ? WHERE objectId = ?",
|
||||||
|
objectData, objectID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update object: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check update result: %w", err)
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
return fmt.Errorf("object not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteObject removes an object by ID.
|
||||||
|
func DeleteObject(objectID string) error {
|
||||||
|
result, err := DB.Exec("DELETE FROM objects WHERE objectId = ?", objectID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete object: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check delete result: %w", err)
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
return fmt.Errorf("object not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createObjectRequest is the expected body for POST /objects.
|
||||||
|
type createObjectRequest struct {
|
||||||
|
ObjectID string `json:"objectId"`
|
||||||
|
UserID string `json:"userId" binding:"required"`
|
||||||
|
ObjectData string `json:"objectData" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func createObjectHandler(c *gin.Context) {
|
||||||
|
var req createObjectRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
objectID := req.ObjectID
|
||||||
|
if objectID == "" {
|
||||||
|
objectID = uuid.NewString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := CreateObject(objectID, req.UserID, req.ObjectData); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"status": "created", "objectId": objectID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func getObjectHandler(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
obj, err := GetObject(id)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "object not found" {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getObjectsByUserHandler(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
objects, err := GetObjectsByUser(id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, objects)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateObjectRequest is the expected body for PUT /objects/:id.
|
||||||
|
type updateObjectRequest struct {
|
||||||
|
ObjectData string `json:"objectData" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateObjectHandler(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
var req updateObjectRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := UpdateObject(id, req.ObjectData); err != nil {
|
||||||
|
if err.Error() == "object not found" {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteObjectHandler(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if err := DeleteObject(id); err != nil {
|
||||||
|
if err.Error() == "object not found" {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
### Backend
|
||||||
|
## UserTable
|
||||||
|
1. name
|
||||||
|
2. rootObjectId
|
||||||
|
|
||||||
|
# create
|
||||||
|
POST /user
|
||||||
|
id
|
||||||
|
name
|
||||||
|
create rootObject and attach objectId to user
|
||||||
|
|
||||||
|
# read
|
||||||
|
GET /user/<id>
|
||||||
|
fetch all objects associated with this userId (name)
|
||||||
|
rootObjectId
|
||||||
|
objects[{objectId, objectData}]
|
||||||
|
|
||||||
|
# update
|
||||||
|
PUT /user/<id>
|
||||||
|
name
|
||||||
|
|
||||||
|
# delete
|
||||||
|
DELETE /user/<id>
|
||||||
|
delete all associated objects
|
||||||
|
delete user
|
||||||
|
|
||||||
|
## ObjectTable
|
||||||
|
1. id
|
||||||
|
2. userId
|
||||||
|
3. data - encrypted JSON object
|
||||||
|
|
||||||
|
# create
|
||||||
|
POST /object
|
||||||
|
generate id
|
||||||
|
userId
|
||||||
|
data
|
||||||
|
return id
|
||||||
|
the client is responsible for updating the parent object's objectId array
|
||||||
|
|
||||||
|
# read
|
||||||
|
GET /object/<id>
|
||||||
|
data
|
||||||
|
|
||||||
|
# update
|
||||||
|
PUT /object/<id>
|
||||||
|
data
|
||||||
|
the client is responsible for updating the parent object's objectId array
|
||||||
|
|
||||||
|
# delete
|
||||||
|
DELETE /object/<id>
|
||||||
|
the client is responsible for updating the parent object's objectId array
|
||||||
|
|
||||||
|
... that's it
|
||||||
|
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
Objects are JSON objects
|
||||||
|
Can be root idk
|
||||||
|
|
||||||
|
Hats/Views are scopes for other objects (like work, home, etc)
|
||||||
|
|
||||||
|
Projects are parent objects for kanbans/tasks???
|
||||||
|
or not???
|
||||||
|
maybe parents for everything?
|
||||||
|
|
||||||
|
All parent objects also have a default parent. for easy pick up and use.
|
||||||
|
|
||||||
|
children objects:
|
||||||
|
tasks - checkbox object, can have notes
|
||||||
|
notes -
|
||||||
+197
@@ -0,0 +1,197 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User represents a row in the users table.
|
||||||
|
type User struct {
|
||||||
|
UserID string
|
||||||
|
Name string
|
||||||
|
RootObjectID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser inserts a new user into the users table.
|
||||||
|
func CreateUser(userID, name, rootObjectID string) error {
|
||||||
|
_, err := DB.Exec(
|
||||||
|
"INSERT INTO users (userId, name, rootObjectId) VALUES (?, ?, ?)",
|
||||||
|
userID, name, rootObjectID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create user: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUser retrieves a user by ID.
|
||||||
|
func GetUser(userID string) (User, error) {
|
||||||
|
var user User
|
||||||
|
err := DB.QueryRow(
|
||||||
|
"SELECT userId, name, rootObjectId FROM users WHERE userId = ?",
|
||||||
|
userID,
|
||||||
|
).Scan(&user.UserID, &user.Name, &user.RootObjectID)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return User{}, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
return User{}, fmt.Errorf("failed to get user: %w", err)
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUser updates the display name for a user.
|
||||||
|
func UpdateUser(userID, name string) error {
|
||||||
|
result, err := DB.Exec(
|
||||||
|
"UPDATE users SET name = ? WHERE userId = ?",
|
||||||
|
name, userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update user: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check update result: %w", err)
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
return fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteObjectsByUser removes all objects belonging to a user.
|
||||||
|
func DeleteObjectsByUser(userID string) error {
|
||||||
|
_, err := DB.Exec("DELETE FROM objects WHERE userId = ?", userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete user objects: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser removes a user by ID and all associated objects.
|
||||||
|
func DeleteUser(userID string) error {
|
||||||
|
if err := DeleteObjectsByUser(userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := DB.Exec("DELETE FROM users WHERE userId = ?", userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete user: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check delete result: %w", err)
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
return fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createUserRequest is the expected body for POST /users.
|
||||||
|
type createUserRequest struct {
|
||||||
|
UserID string `json:"userId" binding:"required"`
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func createUserHandler(c *gin.Context) {
|
||||||
|
var req createUserRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rootObjectID := uuid.NewString()
|
||||||
|
rootObjectData := `{"type":"root","childIds":[]}`
|
||||||
|
|
||||||
|
if err := CreateObject(rootObjectID, req.UserID, rootObjectData); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := CreateUser(req.UserID, req.Name, rootObjectID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"status": "created", "rootObjectId": rootObjectID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUserHandler(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
user, err := GetUser(id)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "user not found" {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
objects, err := GetObjectsByUser(id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return objects with camelCase keys to match the frontend contract.
|
||||||
|
formattedObjects := make([]gin.H, 0, len(objects))
|
||||||
|
for _, obj := range objects {
|
||||||
|
formattedObjects = append(formattedObjects, gin.H{
|
||||||
|
"objectId": obj.ObjectID,
|
||||||
|
"userId": obj.UserID,
|
||||||
|
"objectData": obj.ObjectData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"userId": user.UserID,
|
||||||
|
"name": user.Name,
|
||||||
|
"rootObjectId": user.RootObjectID,
|
||||||
|
"objects": formattedObjects,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateUserRequest is the expected body for PUT /users/:id.
|
||||||
|
type updateUserRequest struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUserHandler(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
var req updateUserRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := UpdateUser(id, req.Name); err != nil {
|
||||||
|
if err.Error() == "user not found" {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteUserHandler(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if err := DeleteUser(id); err != nil {
|
||||||
|
if err.Error() == "user not found" {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# Frontend Architecture
|
||||||
|
|
||||||
|
## Role of the frontend
|
||||||
|
|
||||||
|
The frontend owns **all business logic**. It defines the object types, builds the tree from parent/child references, renders the UI, and is responsible for keeping the local state in sync with the backend.
|
||||||
|
|
||||||
|
The backend is treated as a dumb encrypted sync target: it stores opaque JSON blobs and returns them on request. The frontend encrypts and decrypts data, parses blobs into typed models, and maintains all relationships.
|
||||||
|
|
||||||
|
## Object model
|
||||||
|
|
||||||
|
All domain objects are stored as rows in the backend `objects` table. Each row contains an `objectId`, a `userId`, and an `objectData` string (eventually encrypted JSON).
|
||||||
|
|
||||||
|
The frontend recognizes four typed objects:
|
||||||
|
|
||||||
|
### `Root`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
objectId: string;
|
||||||
|
type: 'root';
|
||||||
|
childIds: string[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every user has exactly one root object. It is the entry point for rendering the tree.
|
||||||
|
|
||||||
|
### `Project`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
objectId: string;
|
||||||
|
type: 'project';
|
||||||
|
name: string;
|
||||||
|
childIds: string[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A project is a container for tasks, notes, and other projects.
|
||||||
|
|
||||||
|
### `Task`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
objectId: string;
|
||||||
|
type: 'task';
|
||||||
|
text: string;
|
||||||
|
status: 'not-started' | 'in-progress' | 'completed' | 'cancelled';
|
||||||
|
childIds: string[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A task is a checkbox-style item that may contain child tasks or notes.
|
||||||
|
|
||||||
|
### `Note`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
objectId: string;
|
||||||
|
type: 'note';
|
||||||
|
text: string;
|
||||||
|
childIds: string[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A note is a free-text object that may contain child notes or tasks.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
1. **Load**: `objectsStore.loadObjects(userId)` calls `GET /users/:id`, which returns the user profile (`userId`, `name`, `rootObjectId`) and all associated objects.
|
||||||
|
2. **Parse**: `objectParser.js` parses each `objectData` JSON string into a typed object and stores them in a `Map<objectId, TypedObject>`.
|
||||||
|
3. **Render**: Components read from the store map and render children by following `childIds`.
|
||||||
|
4. **Mutate**: User actions update the local store immediately, then persist the changed object(s) to the backend with `PUT /objects/:id`.
|
||||||
|
5. **Sync**: Eventually, the frontend will compare local state with server state using per-object versioning and resolve conflicts.
|
||||||
|
|
||||||
|
## Key modules
|
||||||
|
|
||||||
|
### `src/lib/models/*.js`
|
||||||
|
|
||||||
|
Pure factory functions for creating typed objects. They contain no side effects and no backend knowledge.
|
||||||
|
|
||||||
|
### `src/lib/services/objectParser.js`
|
||||||
|
|
||||||
|
Converts backend `ObjectRecord` rows into typed domain objects and serializes typed objects back into the JSON payload stored in `objectData`.
|
||||||
|
|
||||||
|
- `parseObjectRecord(record)` — parse a single row.
|
||||||
|
- `parseObjectRecords(records)` — parse many rows into a `Map` and identify the root object.
|
||||||
|
- `serializeTypedObject(obj)` — convert a typed object into the JSON string stored in `objectData`.
|
||||||
|
|
||||||
|
### `src/lib/services/objects.js`
|
||||||
|
|
||||||
|
Thin wrapper around the object HTTP endpoints.
|
||||||
|
|
||||||
|
### `src/lib/services/users.js`
|
||||||
|
|
||||||
|
Thin wrapper around the user HTTP endpoints. Routes use plural REST conventions (`/users`, `/objects`).
|
||||||
|
|
||||||
|
### `src/lib/stores/objects.js`
|
||||||
|
|
||||||
|
The central Svelte store for the object graph. Responsibilities:
|
||||||
|
|
||||||
|
- Load all objects for the current user.
|
||||||
|
- Maintain the `Map` of parsed objects and the `rootId`.
|
||||||
|
- Provide mutation helpers:
|
||||||
|
- `addObject(userId, type, parentId, nameOrText, status)` — create a child and update the parent's `childIds`.
|
||||||
|
- `updateProjectName(objectId, name)` — update a project name.
|
||||||
|
- `updateText(objectId, text)` — update task/note text.
|
||||||
|
- `updateTaskStatus(objectId, status)` — update a task status.
|
||||||
|
- `removeObject(objectId)` — delete an object and remove it from its parent's `childIds`.
|
||||||
|
|
||||||
|
## Rendering
|
||||||
|
|
||||||
|
Components render the tree recursively:
|
||||||
|
|
||||||
|
- `ObjectRenderer.svelte` dispatches to the correct component based on `object.type`.
|
||||||
|
- `Root.svelte`, `Project.svelte`, `Task.svelte`, and `Note.svelte` each render their own data and iterate over `childIds` to render children.
|
||||||
|
|
||||||
|
Components look up child objects from `$objectsStore.objects` by ID. If a child is missing, it is skipped.
|
||||||
|
|
||||||
|
## Parent/child maintenance
|
||||||
|
|
||||||
|
Because the backend cannot decrypt object data, the frontend is responsible for keeping `childIds` arrays consistent.
|
||||||
|
|
||||||
|
- **Create**: insert the new object's ID into the parent's `childIds`, then persist both parent and child.
|
||||||
|
- **Delete**: remove the object's ID from its parent's `childIds`, persist the parent, then delete the object.
|
||||||
|
- **Move**: remove the ID from the old parent's `childIds`, add it to the new parent's `childIds`, and persist both parents.
|
||||||
|
|
||||||
|
## Orphan handling
|
||||||
|
|
||||||
|
Network interruptions can leave orphaned objects in the backend (objects that are no longer referenced by any parent). The planned strategy:
|
||||||
|
|
||||||
|
1. The client-side delete method walks the tree and deletes all descendants explicitly.
|
||||||
|
2. On startup, a future `cleanupOrphans()` method will compare all loaded objects against the reachable tree and delete any object that is not referenced.
|
||||||
|
|
||||||
|
## Known gaps and roadmap
|
||||||
|
|
||||||
|
| Priority | Item | Notes |
|
||||||
|
| -------- | ---- | ----- |
|
||||||
|
| High | Use bulk `GET /users/:id` for loading | Replace the current `getUser` + `getObjectsByUser` + `populateTree` flow with a single request that returns all objects. |
|
||||||
|
| High | Remove N+1 tree walking | `populateTree` fetches missing children one by one. Once the bulk endpoint is used, this can be deleted. |
|
||||||
|
| Medium | Encryption/decryption | `objectData` should be encrypted before sending and decrypted after receiving. Deferred until core flow is solid. |
|
||||||
|
| Medium | Offline support / local-first | Client-generated IDs are a first step. Eventually the store should work against a local cache and sync in the background. |
|
||||||
|
| Medium | Optimistic mutation error handling | If a persist fails, local state and server drift. Need pending/conflict states. |
|
||||||
|
| Medium | Unknown type fallback | Currently unknown types are silently dropped. Add a fallback renderer or safe ignore policy. |
|
||||||
|
| Low | Parent index | `removeObject` scans all objects to find the parent. Maintain a `childId -> parentId` index for O(1) lookup. |
|
||||||
|
| Low | Subtree move / duplicate | Not yet implemented. |
|
||||||
|
|
||||||
|
## Why the business logic lives in the frontend
|
||||||
|
|
||||||
|
Because the backend is intended to be an encrypted sync target, it cannot understand the data it stores. Moving tree logic, validation, and rendering decisions to the frontend keeps the server simple and compatible with E2EE. The trade-off is that the frontend must be defensive about missing objects, unknown types, and sync conflicts.
|
||||||
Generated
+1074
-1
File diff suppressed because it is too large
Load Diff
@@ -7,13 +7,18 @@
|
|||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"prepare": "svelte-kit sync || echo ''"
|
"prepare": "svelte-kit sync || echo ''"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-static": "^3.0.8",
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
"@sveltejs/kit": "^2.63.0",
|
"@sveltejs/kit": "^2.63.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
|
"@testing-library/svelte": "^5.4.2",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"svelte": "^5.56.1",
|
"svelte": "^5.56.1",
|
||||||
"vite": "^8.0.16"
|
"vite": "^8.0.16",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,12 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="text-scale" content="scale" />
|
<meta name="text-scale" content="scale" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: black;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* @typedef {import('$lib/models/note.js').Note} Note
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { objectsStore } from '$lib/stores/objects.js';
|
||||||
|
import ObjectRenderer from './ObjectRenderer.svelte';
|
||||||
|
|
||||||
|
/** @type {{ object: Note, depth?: number }} */
|
||||||
|
let { object, depth = 0 } = $props();
|
||||||
|
|
||||||
|
let editing = $state(false);
|
||||||
|
let draftText = $state('');
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
draftText = $state.snapshot(object).text;
|
||||||
|
editing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveText() {
|
||||||
|
await objectsStore.updateText(object.objectId, draftText);
|
||||||
|
editing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
await objectsStore.removeObject(object.objectId);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<article class="note">
|
||||||
|
<div class="header">
|
||||||
|
{#if editing}
|
||||||
|
<input type="text" bind:value={draftText} />
|
||||||
|
<button onclick={saveText}>Save</button>
|
||||||
|
<button onclick={cancelEdit}>Cancel</button>
|
||||||
|
{:else}
|
||||||
|
<p class="note-text">{object.text}</p>
|
||||||
|
<button onclick={startEdit}>Edit</button>
|
||||||
|
{/if}
|
||||||
|
<button onclick={handleDelete}>Delete</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="children">
|
||||||
|
{#each object.childIds as childId (childId)}
|
||||||
|
{@const childObject = $objectsStore.objects.get(childId)}
|
||||||
|
{#if childObject}
|
||||||
|
<ObjectRenderer object={childObject} depth={depth + 1} />
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.note {
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-text {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.children {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* @typedef {import('$lib/services/objectParser.js').TypedObject} TypedObject
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Root from './Root.svelte';
|
||||||
|
import Project from './Project.svelte';
|
||||||
|
import Task from './Task.svelte';
|
||||||
|
import Note from './Note.svelte';
|
||||||
|
|
||||||
|
/** @type {{ object: TypedObject, depth?: number }} */
|
||||||
|
let { object, depth = 0 } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="object-renderer" style:padding-left={`${depth * 1.5}rem`}>
|
||||||
|
{#if object.type === 'root'}
|
||||||
|
<Root {object} {depth} />
|
||||||
|
{:else if object.type === 'project'}
|
||||||
|
<Project {object} {depth} />
|
||||||
|
{:else if object.type === 'task'}
|
||||||
|
<Task {object} {depth} />
|
||||||
|
{:else if object.type === 'note'}
|
||||||
|
<Note {object} {depth} />
|
||||||
|
{:else}
|
||||||
|
<p>Unknown object type: {object.type}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* @typedef {import('$lib/models/project.js').Project} Project
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { objectsStore } from '$lib/stores/objects.js';
|
||||||
|
import ObjectRenderer from './ObjectRenderer.svelte';
|
||||||
|
|
||||||
|
/** @type {{ object: Project, depth?: number }} */
|
||||||
|
let { object, depth = 0 } = $props();
|
||||||
|
|
||||||
|
let editing = $state(false);
|
||||||
|
let draftName = $state('');
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
draftName = $state.snapshot(object).name;
|
||||||
|
editing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveName() {
|
||||||
|
await objectsStore.updateProjectName(object.objectId, draftName);
|
||||||
|
editing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
await objectsStore.removeObject(object.objectId);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<article class="project">
|
||||||
|
<div class="header">
|
||||||
|
{#if editing}
|
||||||
|
<input type="text" bind:value={draftName} />
|
||||||
|
<button onclick={saveName}>Save</button>
|
||||||
|
<button onclick={cancelEdit}>Cancel</button>
|
||||||
|
{:else}
|
||||||
|
<h3>{object.name}</h3>
|
||||||
|
<button onclick={startEdit}>Edit</button>
|
||||||
|
{/if}
|
||||||
|
<button onclick={handleDelete}>Delete</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="children">
|
||||||
|
{#each object.childIds as childId (childId)}
|
||||||
|
{@const childObject = $objectsStore.objects.get(childId)}
|
||||||
|
{#if childObject}
|
||||||
|
<ObjectRenderer object={childObject} depth={depth + 1} />
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.project {
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h3 {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.children {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* @typedef {import('$lib/models/root.js').Root} Root
|
||||||
|
* @typedef {import('$lib/services/objectParser.js').TypedObject} TypedObject
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { objectsStore } from '$lib/stores/objects.js';
|
||||||
|
import ObjectRenderer from './ObjectRenderer.svelte';
|
||||||
|
|
||||||
|
/** @type {{ object: Root, depth?: number }} */
|
||||||
|
let { object, depth = 0 } = $props();
|
||||||
|
|
||||||
|
/** @type {TypedObject | undefined} */
|
||||||
|
let childObject = $derived(undefined);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="root">
|
||||||
|
{#if depth === 0}
|
||||||
|
<h2>Root</h2>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each object.childIds as childId (childId)}
|
||||||
|
{@const childObject = $objectsStore.objects.get(childId)}
|
||||||
|
{#if childObject}
|
||||||
|
<ObjectRenderer object={childObject} depth={depth + 1} />
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.root {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* @typedef {import('$lib/models/task.js').Task} Task
|
||||||
|
* @typedef {import('$lib/models/task.js').TaskStatus} TaskStatus
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { objectsStore } from '$lib/stores/objects.js';
|
||||||
|
import ObjectRenderer from './ObjectRenderer.svelte';
|
||||||
|
|
||||||
|
/** @type {{ object: Task, depth?: number }} */
|
||||||
|
let { object, depth = 0 } = $props();
|
||||||
|
|
||||||
|
let editing = $state(false);
|
||||||
|
let draftText = $state('');
|
||||||
|
|
||||||
|
const statusOptions = /** @type {TaskStatus[]} */ ([
|
||||||
|
'not-started',
|
||||||
|
'in-progress',
|
||||||
|
'completed',
|
||||||
|
'cancelled'
|
||||||
|
]);
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
draftText = $state.snapshot(object).text;
|
||||||
|
editing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveText() {
|
||||||
|
await objectsStore.updateText(object.objectId, draftText);
|
||||||
|
editing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Event} event
|
||||||
|
*/
|
||||||
|
async function handleStatusChange(event) {
|
||||||
|
const target = /** @type {HTMLSelectElement} */ (event.target);
|
||||||
|
await objectsStore.updateTaskStatus(object.objectId, /** @type {TaskStatus} */ (target.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
await objectsStore.removeObject(object.objectId);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<article class="task">
|
||||||
|
<div class="header">
|
||||||
|
{#if editing}
|
||||||
|
<input type="text" bind:value={draftText} />
|
||||||
|
<button onclick={saveText}>Save</button>
|
||||||
|
<button onclick={cancelEdit}>Cancel</button>
|
||||||
|
{:else}
|
||||||
|
<p class="task-text">{object.text}</p>
|
||||||
|
<button onclick={startEdit}>Edit</button>
|
||||||
|
{/if}
|
||||||
|
<select value={object.status} onchange={handleStatusChange}>
|
||||||
|
{#each statusOptions as status}
|
||||||
|
<option value={status}>{status}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<button onclick={handleDelete}>Delete</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="children">
|
||||||
|
{#each object.childIds as childId (childId)}
|
||||||
|
{@const childObject = $objectsStore.objects.get(childId)}
|
||||||
|
{#if childObject}
|
||||||
|
<ObjectRenderer object={childObject} depth={depth + 1} />
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.task {
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-text {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.children {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* @typedef {Object} User
|
* @typedef {Object} User
|
||||||
* @property {string} userId
|
* @property {string} userId
|
||||||
|
* @property {string} name
|
||||||
* @property {string} rootObjectId
|
* @property {string} rootObjectId
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -8,9 +9,10 @@
|
|||||||
* Create a User object.
|
* Create a User object.
|
||||||
*
|
*
|
||||||
* @param {string} userId
|
* @param {string} userId
|
||||||
|
* @param {string} name
|
||||||
* @param {string} rootObjectId
|
* @param {string} rootObjectId
|
||||||
* @returns {User}
|
* @returns {User}
|
||||||
*/
|
*/
|
||||||
export function createUserModel(userId, rootObjectId) {
|
export function createUserModel(userId, name, rootObjectId) {
|
||||||
return { userId, rootObjectId };
|
return { userId, name, rootObjectId };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { createRootModel } from '$lib/models/root.js';
|
||||||
|
import { createProjectModel } from '$lib/models/project.js';
|
||||||
|
import { createTaskModel } from '$lib/models/task.js';
|
||||||
|
import { createNoteModel } from '$lib/models/note.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('$lib/models/root.js').Root} Root
|
||||||
|
* @typedef {import('$lib/models/project.js').Project} Project
|
||||||
|
* @typedef {import('$lib/models/task.js').Task} Task
|
||||||
|
* @typedef {import('$lib/models/note.js').Note} Note
|
||||||
|
* @typedef {Root | Project | Task | Note} TypedObject
|
||||||
|
* @typedef {import('./objects.js').ObjectRecord} ObjectRecord
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a backend ObjectRecord into a typed domain object.
|
||||||
|
*
|
||||||
|
* @param {ObjectRecord} record
|
||||||
|
* @returns {TypedObject | null}
|
||||||
|
*/
|
||||||
|
export function parseObjectRecord(record) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(record.objectData);
|
||||||
|
|
||||||
|
switch (data.type) {
|
||||||
|
case 'root':
|
||||||
|
return createRootModel(record.objectId, data.childIds ?? []);
|
||||||
|
case 'project':
|
||||||
|
return createProjectModel(record.objectId, data.name ?? '', data.childIds ?? []);
|
||||||
|
case 'task':
|
||||||
|
return createTaskModel(
|
||||||
|
record.objectId,
|
||||||
|
data.text ?? '',
|
||||||
|
data.status ?? 'not-started',
|
||||||
|
data.childIds ?? []
|
||||||
|
);
|
||||||
|
case 'note':
|
||||||
|
return createNoteModel(record.objectId, data.text ?? '', data.childIds ?? []);
|
||||||
|
default:
|
||||||
|
console.warn(`Unknown object type: ${data.type}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to parse object ${record.objectId}:`, err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a list of ObjectRecords into a map of typed objects.
|
||||||
|
* Also returns the root object ID if one is found.
|
||||||
|
*
|
||||||
|
* @param {ObjectRecord[]} records
|
||||||
|
* @returns {{ objects: Map<string, TypedObject>, rootId: string | null }}
|
||||||
|
*/
|
||||||
|
export function parseObjectRecords(records) {
|
||||||
|
/** @type {Map<string, TypedObject>} */
|
||||||
|
const objects = new Map();
|
||||||
|
/** @type {string | null} */
|
||||||
|
let rootId = null;
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
const parsed = parseObjectRecord(record);
|
||||||
|
if (!parsed) continue;
|
||||||
|
|
||||||
|
objects.set(parsed.objectId, parsed);
|
||||||
|
|
||||||
|
if (parsed.type === 'root') {
|
||||||
|
rootId = parsed.objectId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { objects, rootId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a typed domain object into the JSON payload stored in objectData.
|
||||||
|
*
|
||||||
|
* @param {TypedObject} obj
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function serializeTypedObject(obj) {
|
||||||
|
const { objectId, ...data } = obj;
|
||||||
|
return JSON.stringify(data);
|
||||||
|
}
|
||||||
@@ -8,17 +8,18 @@ import { apiFetch } from './api.js';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new object.
|
* Create a new object. The backend will use the supplied objectId if provided,
|
||||||
|
* otherwise it will assign a UUID.
|
||||||
*
|
*
|
||||||
* @param {string} objectId
|
|
||||||
* @param {string} userId
|
* @param {string} userId
|
||||||
* @param {string} objectData
|
* @param {string} objectData
|
||||||
* @returns {Promise<{status: string}>}
|
* @param {string} [objectId]
|
||||||
|
* @returns {Promise<{status: string, objectId: string}>}
|
||||||
*/
|
*/
|
||||||
export function createObject(objectId, userId, objectData) {
|
export function createObject(userId, objectData, objectId) {
|
||||||
return apiFetch('/objects', {
|
return apiFetch('/objects', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { objectId, userId, objectData }
|
body: { userId, objectData, objectId }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,25 +3,27 @@ import { apiFetch } from './api.js';
|
|||||||
/**
|
/**
|
||||||
* @typedef {Object} User
|
* @typedef {Object} User
|
||||||
* @property {string} userId
|
* @property {string} userId
|
||||||
|
* @property {string} name
|
||||||
* @property {string} rootObjectId
|
* @property {string} rootObjectId
|
||||||
|
* @property {Object[]} objects
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new user.
|
* Create a new user. The backend will create an empty root object.
|
||||||
*
|
*
|
||||||
* @param {string} userId
|
* @param {string} userId
|
||||||
* @param {string} rootObjectId
|
* @param {string} name
|
||||||
* @returns {Promise<{status: string}>}
|
* @returns {Promise<{status: string, rootObjectId: string}>}
|
||||||
*/
|
*/
|
||||||
export function createUser(userId, rootObjectId) {
|
export function createUser(userId, name) {
|
||||||
return apiFetch('/users', {
|
return apiFetch('/users', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { userId, rootObjectId }
|
body: { userId, name }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch a user by ID.
|
* Fetch a user by ID. Returns the user profile and all associated objects.
|
||||||
*
|
*
|
||||||
* @param {string} userId
|
* @param {string} userId
|
||||||
* @returns {Promise<User>}
|
* @returns {Promise<User>}
|
||||||
@@ -31,16 +33,16 @@ export function getUser(userId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a user's root object.
|
* Update a user's display name.
|
||||||
*
|
*
|
||||||
* @param {string} userId
|
* @param {string} userId
|
||||||
* @param {string} rootObjectId
|
* @param {string} name
|
||||||
* @returns {Promise<{status: string}>}
|
* @returns {Promise<{status: string}>}
|
||||||
*/
|
*/
|
||||||
export function updateUser(userId, rootObjectId) {
|
export function updateUser(userId, name) {
|
||||||
return apiFetch(`/users/${userId}`, {
|
return apiFetch(`/users/${userId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: { rootObjectId }
|
body: { name }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { writable, get } from 'svelte/store';
|
||||||
|
import { createObject, updateObject, deleteObject } from '$lib/services/objects.js';
|
||||||
|
import { getUser } from '$lib/services/users.js';
|
||||||
|
import { parseObjectRecords, serializeTypedObject } from '$lib/services/objectParser.js';
|
||||||
|
import { createRootModel } from '$lib/models/root.js';
|
||||||
|
import { createProjectModel } from '$lib/models/project.js';
|
||||||
|
import { createTaskModel } from '$lib/models/task.js';
|
||||||
|
import { createNoteModel } from '$lib/models/note.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('$lib/services/objectParser.js').TypedObject} TypedObject
|
||||||
|
* @typedef {import('$lib/models/task.js').TaskStatus} TaskStatus
|
||||||
|
*/
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
/** @type {Map<string, TypedObject>} */
|
||||||
|
objects: new Map(),
|
||||||
|
/** @type {string | null} */
|
||||||
|
rootId: null,
|
||||||
|
loading: false,
|
||||||
|
error: /** @type {string | null} */ (null)
|
||||||
|
};
|
||||||
|
|
||||||
|
function createObjectsStore() {
|
||||||
|
const { subscribe, set, update } = writable(initialState);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the store to its initial empty state. Intended for tests.
|
||||||
|
*/
|
||||||
|
function reset() {
|
||||||
|
set(initialState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all objects for a user starting from the root object.
|
||||||
|
* Creates a root object if the user has none.
|
||||||
|
*
|
||||||
|
* @param {string} userId
|
||||||
|
*/
|
||||||
|
async function loadObjects(userId) {
|
||||||
|
update((state) => ({ ...state, loading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await getUser(userId);
|
||||||
|
let rootId = user.rootObjectId;
|
||||||
|
|
||||||
|
/** @type {Map<string, TypedObject>} */
|
||||||
|
const objects = new Map();
|
||||||
|
|
||||||
|
// The bulk user endpoint returns all objects in one response.
|
||||||
|
const records = user.objects ?? [];
|
||||||
|
const parsed = parseObjectRecords(records);
|
||||||
|
parsed.objects.forEach((obj, id) => objects.set(id, obj));
|
||||||
|
if (parsed.rootId) {
|
||||||
|
rootId = parsed.rootId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rootId) {
|
||||||
|
const root = createRootModel(crypto.randomUUID());
|
||||||
|
rootId = root.objectId;
|
||||||
|
const response = await createObject(userId, serializeTypedObject(root), rootId);
|
||||||
|
rootId = response.objectId;
|
||||||
|
objects.set(rootId, { ...root, objectId: rootId });
|
||||||
|
}
|
||||||
|
|
||||||
|
update((state) => ({ ...state, objects, rootId, loading: false }));
|
||||||
|
} catch (err) {
|
||||||
|
update((state) => ({
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
error: err instanceof Error ? err.message : 'Failed to load objects'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a single object to the backend and update local state.
|
||||||
|
*
|
||||||
|
* @param {string} objectId
|
||||||
|
*/
|
||||||
|
async function persistObject(objectId) {
|
||||||
|
const state = get({ subscribe });
|
||||||
|
const obj = state.objects.get(objectId);
|
||||||
|
if (!obj) return;
|
||||||
|
|
||||||
|
await updateObject(objectId, serializeTypedObject(obj));
|
||||||
|
update((state) => ({ ...state, objects: new Map(state.objects) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new object, attach it to a parent, and persist both.
|
||||||
|
*
|
||||||
|
* @param {string} userId
|
||||||
|
* @param {'project' | 'task' | 'note'} type
|
||||||
|
* @param {string} parentId
|
||||||
|
* @param {string} [nameOrText='']
|
||||||
|
* @param {TaskStatus} [status='not-started']
|
||||||
|
*/
|
||||||
|
async function addObject(userId, type, parentId, nameOrText = '', status = 'not-started') {
|
||||||
|
const state = get({ subscribe });
|
||||||
|
const parent = state.objects.get(parentId);
|
||||||
|
if (!parent) throw new Error('Parent not found');
|
||||||
|
|
||||||
|
/** @type {TypedObject} */
|
||||||
|
let obj;
|
||||||
|
switch (type) {
|
||||||
|
case 'project':
|
||||||
|
obj = createProjectModel(crypto.randomUUID(), nameOrText);
|
||||||
|
break;
|
||||||
|
case 'task':
|
||||||
|
obj = createTaskModel(crypto.randomUUID(), nameOrText, status);
|
||||||
|
break;
|
||||||
|
case 'note':
|
||||||
|
obj = createNoteModel(crypto.randomUUID(), nameOrText);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported object type: ${type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await createObject(userId, serializeTypedObject(obj), obj.objectId);
|
||||||
|
const created = { ...obj, objectId: response.objectId };
|
||||||
|
|
||||||
|
const updatedParent = { ...parent, childIds: [...parent.childIds, created.objectId] };
|
||||||
|
|
||||||
|
update((state) => {
|
||||||
|
const objects = new Map(state.objects);
|
||||||
|
objects.set(created.objectId, created);
|
||||||
|
objects.set(parentId, updatedParent);
|
||||||
|
return { ...state, objects };
|
||||||
|
});
|
||||||
|
|
||||||
|
await persistObject(parentId);
|
||||||
|
await persistObject(created.objectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a project name.
|
||||||
|
*
|
||||||
|
* @param {string} objectId
|
||||||
|
* @param {string} name
|
||||||
|
*/
|
||||||
|
async function updateProjectName(objectId, name) {
|
||||||
|
const state = get({ subscribe });
|
||||||
|
const obj = state.objects.get(objectId);
|
||||||
|
if (!obj || obj.type !== 'project') return;
|
||||||
|
|
||||||
|
update((state) => {
|
||||||
|
const objects = new Map(state.objects);
|
||||||
|
objects.set(objectId, { ...obj, name });
|
||||||
|
return { ...state, objects };
|
||||||
|
});
|
||||||
|
await persistObject(objectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update text for a task or note.
|
||||||
|
*
|
||||||
|
* @param {string} objectId
|
||||||
|
* @param {string} text
|
||||||
|
*/
|
||||||
|
async function updateText(objectId, text) {
|
||||||
|
const state = get({ subscribe });
|
||||||
|
const obj = state.objects.get(objectId);
|
||||||
|
if (!obj || (obj.type !== 'task' && obj.type !== 'note')) return;
|
||||||
|
|
||||||
|
update((state) => {
|
||||||
|
const objects = new Map(state.objects);
|
||||||
|
objects.set(objectId, { ...obj, text });
|
||||||
|
return { ...state, objects };
|
||||||
|
});
|
||||||
|
await persistObject(objectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a task status.
|
||||||
|
*
|
||||||
|
* @param {string} objectId
|
||||||
|
* @param {TaskStatus} status
|
||||||
|
*/
|
||||||
|
async function updateTaskStatus(objectId, status) {
|
||||||
|
const state = get({ subscribe });
|
||||||
|
const obj = state.objects.get(objectId);
|
||||||
|
if (!obj || obj.type !== 'task') return;
|
||||||
|
|
||||||
|
update((state) => {
|
||||||
|
const objects = new Map(state.objects);
|
||||||
|
objects.set(objectId, { ...obj, status });
|
||||||
|
return { ...state, objects };
|
||||||
|
});
|
||||||
|
await persistObject(objectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an object and remove it from its parent's childIds.
|
||||||
|
*
|
||||||
|
* @param {string} objectId
|
||||||
|
*/
|
||||||
|
async function removeObject(objectId) {
|
||||||
|
const state = get({ subscribe });
|
||||||
|
if (objectId === state.rootId) {
|
||||||
|
throw new Error('Cannot delete root object');
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = state.objects.get(objectId);
|
||||||
|
if (!obj) return;
|
||||||
|
|
||||||
|
// Find the parent and build an updated version without mutating the original.
|
||||||
|
let parentId = null;
|
||||||
|
let updatedParent = null;
|
||||||
|
for (const [id, parent] of state.objects) {
|
||||||
|
if (parent.childIds.includes(objectId)) {
|
||||||
|
parentId = id;
|
||||||
|
updatedParent = {
|
||||||
|
...parent,
|
||||||
|
childIds: parent.childIds.filter((id) => id !== objectId)
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteObject(objectId);
|
||||||
|
|
||||||
|
update((state) => {
|
||||||
|
const objects = new Map(state.objects);
|
||||||
|
objects.delete(objectId);
|
||||||
|
if (parentId !== null && updatedParent !== null) {
|
||||||
|
objects.set(parentId, updatedParent);
|
||||||
|
}
|
||||||
|
return { ...state, objects };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (parentId !== null) {
|
||||||
|
await persistObject(parentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
reset,
|
||||||
|
loadObjects,
|
||||||
|
addObject,
|
||||||
|
updateProjectName,
|
||||||
|
updateText,
|
||||||
|
updateTaskStatus,
|
||||||
|
removeObject
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const objectsStore = createObjectsStore();
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { objectsStore } from './objects.js';
|
||||||
|
import * as usersService from '$lib/services/users.js';
|
||||||
|
import * as objectsService from '$lib/services/objects.js';
|
||||||
|
|
||||||
|
// Mock the HTTP service modules so tests never hit a real backend.
|
||||||
|
vi.mock('$lib/services/users.js', () => ({
|
||||||
|
getUser: vi.fn(),
|
||||||
|
createUser: vi.fn(),
|
||||||
|
updateUser: vi.fn(),
|
||||||
|
deleteUser: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/services/objects.js', () => ({
|
||||||
|
createObject: vi.fn(),
|
||||||
|
getObject: vi.fn(),
|
||||||
|
getObjectsByUser: vi.fn(),
|
||||||
|
updateObject: vi.fn(),
|
||||||
|
deleteObject: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makeRecord(objectId, data) {
|
||||||
|
return {
|
||||||
|
objectId,
|
||||||
|
objectData: JSON.stringify(data)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('objectsStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset the store and mocks before each test.
|
||||||
|
objectsStore.reset();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadObjects', () => {
|
||||||
|
it('loads a user and their object graph into the store', async () => {
|
||||||
|
const rootId = 'root-1';
|
||||||
|
const projectId = 'project-1';
|
||||||
|
|
||||||
|
usersService.getUser.mockResolvedValue({
|
||||||
|
userId: 'user-1',
|
||||||
|
name: 'Test User',
|
||||||
|
rootObjectId: rootId,
|
||||||
|
objects: [
|
||||||
|
makeRecord(rootId, { type: 'root', childIds: [projectId] }),
|
||||||
|
makeRecord(projectId, { type: 'project', name: 'Test Project', childIds: [] })
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
await objectsStore.loadObjects('user-1');
|
||||||
|
|
||||||
|
const state = get(objectsStore);
|
||||||
|
expect(state.loading).toBe(false);
|
||||||
|
expect(state.error).toBeNull();
|
||||||
|
expect(state.rootId).toBe(rootId);
|
||||||
|
expect(state.objects.has(rootId)).toBe(true);
|
||||||
|
expect(state.objects.has(projectId)).toBe(true);
|
||||||
|
expect(state.objects.get(projectId).name).toBe('Test Project');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a root object when the user has none', async () => {
|
||||||
|
const generatedRootId = 'new-root-1';
|
||||||
|
objectsService.createObject.mockResolvedValue({ status: 'created', objectId: generatedRootId });
|
||||||
|
|
||||||
|
usersService.getUser.mockResolvedValue({
|
||||||
|
userId: 'user-1',
|
||||||
|
name: 'Test User',
|
||||||
|
rootObjectId: null,
|
||||||
|
objects: []
|
||||||
|
});
|
||||||
|
|
||||||
|
await objectsStore.loadObjects('user-1');
|
||||||
|
|
||||||
|
const state = get(objectsStore);
|
||||||
|
expect(state.rootId).toBe(generatedRootId);
|
||||||
|
expect(state.objects.has(generatedRootId)).toBe(true);
|
||||||
|
expect(state.objects.get(generatedRootId).type).toBe('root');
|
||||||
|
expect(objectsService.createObject).toHaveBeenCalledWith(
|
||||||
|
'user-1',
|
||||||
|
expect.stringContaining('"type":"root"'),
|
||||||
|
expect.any(String)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records an error when loading fails', async () => {
|
||||||
|
usersService.getUser.mockRejectedValue(new Error('Network error'));
|
||||||
|
|
||||||
|
await objectsStore.loadObjects('user-1');
|
||||||
|
|
||||||
|
const state = get(objectsStore);
|
||||||
|
expect(state.loading).toBe(false);
|
||||||
|
expect(state.error).toBe('Network error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addObject', () => {
|
||||||
|
it('adds a project under a parent and persists both without mutating the original parent', async () => {
|
||||||
|
const rootId = 'root-1';
|
||||||
|
const projectId = 'project-1';
|
||||||
|
|
||||||
|
usersService.getUser.mockResolvedValue({
|
||||||
|
userId: 'user-1',
|
||||||
|
name: 'Test User',
|
||||||
|
rootObjectId: rootId,
|
||||||
|
objects: [makeRecord(rootId, { type: 'root', childIds: [] })]
|
||||||
|
});
|
||||||
|
|
||||||
|
objectsService.createObject.mockResolvedValue({ status: 'created', objectId: projectId });
|
||||||
|
objectsService.updateObject.mockResolvedValue({ status: 'updated' });
|
||||||
|
|
||||||
|
await objectsStore.loadObjects('user-1');
|
||||||
|
const stateBefore = get(objectsStore);
|
||||||
|
const originalRoot = stateBefore.objects.get(rootId);
|
||||||
|
|
||||||
|
await objectsStore.addObject('user-1', 'project', rootId, 'New Project');
|
||||||
|
|
||||||
|
const stateAfter = get(objectsStore);
|
||||||
|
const updatedRoot = stateAfter.objects.get(rootId);
|
||||||
|
const createdProject = stateAfter.objects.get(projectId);
|
||||||
|
|
||||||
|
expect(createdProject.type).toBe('project');
|
||||||
|
expect(createdProject.name).toBe('New Project');
|
||||||
|
expect(updatedRoot.childIds).toContain(projectId);
|
||||||
|
|
||||||
|
// Immutability checks.
|
||||||
|
expect(originalRoot.childIds).not.toContain(projectId);
|
||||||
|
expect(updatedRoot).not.toBe(originalRoot);
|
||||||
|
expect(updatedRoot.childIds).not.toBe(originalRoot.childIds);
|
||||||
|
|
||||||
|
expect(objectsService.createObject).toHaveBeenCalledWith(
|
||||||
|
'user-1',
|
||||||
|
expect.stringContaining('"name":"New Project"'),
|
||||||
|
expect.any(String)
|
||||||
|
);
|
||||||
|
expect(objectsService.updateObject).toHaveBeenCalledWith(rootId, expect.any(String));
|
||||||
|
expect(objectsService.updateObject).toHaveBeenCalledWith(projectId, expect.any(String));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a task with the given status', async () => {
|
||||||
|
const rootId = 'root-1';
|
||||||
|
const taskId = 'task-1';
|
||||||
|
|
||||||
|
usersService.getUser.mockResolvedValue({
|
||||||
|
userId: 'user-1',
|
||||||
|
name: 'Test User',
|
||||||
|
rootObjectId: rootId,
|
||||||
|
objects: [makeRecord(rootId, { type: 'root', childIds: [] })]
|
||||||
|
});
|
||||||
|
|
||||||
|
objectsService.createObject.mockResolvedValue({ status: 'created', objectId: taskId });
|
||||||
|
objectsService.updateObject.mockResolvedValue({ status: 'updated' });
|
||||||
|
|
||||||
|
await objectsStore.loadObjects('user-1');
|
||||||
|
await objectsStore.addObject('user-1', 'task', rootId, 'New Task', 'in-progress');
|
||||||
|
|
||||||
|
const state = get(objectsStore);
|
||||||
|
const createdTask = state.objects.get(taskId);
|
||||||
|
expect(createdTask.type).toBe('task');
|
||||||
|
expect(createdTask.text).toBe('New Task');
|
||||||
|
expect(createdTask.status).toBe('in-progress');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when the parent does not exist', async () => {
|
||||||
|
await expect(objectsStore.addObject('user-1', 'project', 'missing-parent', 'X')).rejects.toThrow(
|
||||||
|
'Parent not found'
|
||||||
|
);
|
||||||
|
expect(objectsService.createObject).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateProjectName', () => {
|
||||||
|
it('updates a project name and persists it', async () => {
|
||||||
|
const projectId = 'project-1';
|
||||||
|
|
||||||
|
usersService.getUser.mockResolvedValue({
|
||||||
|
userId: 'user-1',
|
||||||
|
name: 'Test User',
|
||||||
|
rootObjectId: 'root-1',
|
||||||
|
objects: [
|
||||||
|
makeRecord('root-1', { type: 'root', childIds: [projectId] }),
|
||||||
|
makeRecord(projectId, { type: 'project', name: 'Old Name', childIds: [] })
|
||||||
|
]
|
||||||
|
});
|
||||||
|
objectsService.updateObject.mockResolvedValue({ status: 'updated' });
|
||||||
|
|
||||||
|
await objectsStore.loadObjects('user-1');
|
||||||
|
await objectsStore.updateProjectName(projectId, 'New Name');
|
||||||
|
|
||||||
|
const state = get(objectsStore);
|
||||||
|
expect(state.objects.get(projectId).name).toBe('New Name');
|
||||||
|
expect(objectsService.updateObject).toHaveBeenCalledWith(projectId, expect.any(String));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores non-project objects', async () => {
|
||||||
|
const taskId = 'task-1';
|
||||||
|
|
||||||
|
usersService.getUser.mockResolvedValue({
|
||||||
|
userId: 'user-1',
|
||||||
|
name: 'Test User',
|
||||||
|
rootObjectId: 'root-1',
|
||||||
|
objects: [
|
||||||
|
makeRecord('root-1', { type: 'root', childIds: [taskId] }),
|
||||||
|
makeRecord(taskId, { type: 'task', text: 'Task', status: 'not-started', childIds: [] })
|
||||||
|
]
|
||||||
|
});
|
||||||
|
objectsService.updateObject.mockResolvedValue({ status: 'updated' });
|
||||||
|
|
||||||
|
await objectsStore.loadObjects('user-1');
|
||||||
|
await objectsStore.updateProjectName(taskId, 'Should Not Change');
|
||||||
|
|
||||||
|
const state = get(objectsStore);
|
||||||
|
expect(state.objects.get(taskId).text).toBe('Task');
|
||||||
|
expect(state.objects.get(taskId).name).toBeUndefined();
|
||||||
|
expect(objectsService.updateObject).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateText', () => {
|
||||||
|
it('updates task text and persists it', async () => {
|
||||||
|
const taskId = 'task-1';
|
||||||
|
|
||||||
|
usersService.getUser.mockResolvedValue({
|
||||||
|
userId: 'user-1',
|
||||||
|
name: 'Test User',
|
||||||
|
rootObjectId: 'root-1',
|
||||||
|
objects: [
|
||||||
|
makeRecord('root-1', { type: 'root', childIds: [taskId] }),
|
||||||
|
makeRecord(taskId, { type: 'task', text: 'Old', status: 'not-started', childIds: [] })
|
||||||
|
]
|
||||||
|
});
|
||||||
|
objectsService.updateObject.mockResolvedValue({ status: 'updated' });
|
||||||
|
|
||||||
|
await objectsStore.loadObjects('user-1');
|
||||||
|
await objectsStore.updateText(taskId, 'Updated');
|
||||||
|
|
||||||
|
const state = get(objectsStore);
|
||||||
|
expect(state.objects.get(taskId).text).toBe('Updated');
|
||||||
|
expect(objectsService.updateObject).toHaveBeenCalledWith(taskId, expect.any(String));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates note text and persists it', async () => {
|
||||||
|
const noteId = 'note-1';
|
||||||
|
|
||||||
|
usersService.getUser.mockResolvedValue({
|
||||||
|
userId: 'user-1',
|
||||||
|
name: 'Test User',
|
||||||
|
rootObjectId: 'root-1',
|
||||||
|
objects: [
|
||||||
|
makeRecord('root-1', { type: 'root', childIds: [noteId] }),
|
||||||
|
makeRecord(noteId, { type: 'note', text: 'Old', childIds: [] })
|
||||||
|
]
|
||||||
|
});
|
||||||
|
objectsService.updateObject.mockResolvedValue({ status: 'updated' });
|
||||||
|
|
||||||
|
await objectsStore.loadObjects('user-1');
|
||||||
|
await objectsStore.updateText(noteId, 'Updated');
|
||||||
|
|
||||||
|
const state = get(objectsStore);
|
||||||
|
expect(state.objects.get(noteId).text).toBe('Updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores project objects', async () => {
|
||||||
|
const projectId = 'project-1';
|
||||||
|
|
||||||
|
usersService.getUser.mockResolvedValue({
|
||||||
|
userId: 'user-1',
|
||||||
|
name: 'Test User',
|
||||||
|
rootObjectId: 'root-1',
|
||||||
|
objects: [
|
||||||
|
makeRecord('root-1', { type: 'root', childIds: [projectId] }),
|
||||||
|
makeRecord(projectId, { type: 'project', name: 'Project', childIds: [] })
|
||||||
|
]
|
||||||
|
});
|
||||||
|
objectsService.updateObject.mockResolvedValue({ status: 'updated' });
|
||||||
|
|
||||||
|
await objectsStore.loadObjects('user-1');
|
||||||
|
await objectsStore.updateText(projectId, 'Should Not Change');
|
||||||
|
|
||||||
|
const state = get(objectsStore);
|
||||||
|
expect(state.objects.get(projectId).name).toBe('Project');
|
||||||
|
expect(objectsService.updateObject).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateTaskStatus', () => {
|
||||||
|
it('updates a task status and persists it', async () => {
|
||||||
|
const taskId = 'task-1';
|
||||||
|
|
||||||
|
usersService.getUser.mockResolvedValue({
|
||||||
|
userId: 'user-1',
|
||||||
|
name: 'Test User',
|
||||||
|
rootObjectId: 'root-1',
|
||||||
|
objects: [
|
||||||
|
makeRecord('root-1', { type: 'root', childIds: [taskId] }),
|
||||||
|
makeRecord(taskId, { type: 'task', text: 'Task', status: 'not-started', childIds: [] })
|
||||||
|
]
|
||||||
|
});
|
||||||
|
objectsService.updateObject.mockResolvedValue({ status: 'updated' });
|
||||||
|
|
||||||
|
await objectsStore.loadObjects('user-1');
|
||||||
|
await objectsStore.updateTaskStatus(taskId, 'completed');
|
||||||
|
|
||||||
|
const state = get(objectsStore);
|
||||||
|
expect(state.objects.get(taskId).status).toBe('completed');
|
||||||
|
expect(objectsService.updateObject).toHaveBeenCalledWith(taskId, expect.any(String));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores non-task objects', async () => {
|
||||||
|
const noteId = 'note-1';
|
||||||
|
|
||||||
|
usersService.getUser.mockResolvedValue({
|
||||||
|
userId: 'user-1',
|
||||||
|
name: 'Test User',
|
||||||
|
rootObjectId: 'root-1',
|
||||||
|
objects: [
|
||||||
|
makeRecord('root-1', { type: 'root', childIds: [noteId] }),
|
||||||
|
makeRecord(noteId, { type: 'note', text: 'Note', childIds: [] })
|
||||||
|
]
|
||||||
|
});
|
||||||
|
objectsService.updateObject.mockResolvedValue({ status: 'updated' });
|
||||||
|
|
||||||
|
await objectsStore.loadObjects('user-1');
|
||||||
|
await objectsStore.updateTaskStatus(noteId, 'completed');
|
||||||
|
|
||||||
|
const state = get(objectsStore);
|
||||||
|
expect(state.objects.get(noteId).status).toBeUndefined();
|
||||||
|
expect(objectsService.updateObject).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeObject', () => {
|
||||||
|
it('removes a child object and updates its parent without mutating the original parent', async () => {
|
||||||
|
// Arrange: build a simple object graph.
|
||||||
|
// root -> project -> task
|
||||||
|
const rootId = 'root-1';
|
||||||
|
const projectId = 'project-1';
|
||||||
|
const taskId = 'task-1';
|
||||||
|
|
||||||
|
usersService.getUser.mockResolvedValue({
|
||||||
|
userId: 'user-1',
|
||||||
|
name: 'Test User',
|
||||||
|
rootObjectId: rootId,
|
||||||
|
objects: [
|
||||||
|
makeRecord(rootId, { type: 'root', childIds: [projectId] }),
|
||||||
|
makeRecord(projectId, { type: 'project', name: 'Test Project', childIds: [taskId] }),
|
||||||
|
makeRecord(taskId, { type: 'task', text: 'Test task', status: 'not-started', childIds: [] })
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
objectsService.deleteObject.mockResolvedValue({ status: 'deleted' });
|
||||||
|
objectsService.updateObject.mockResolvedValue({ status: 'updated' });
|
||||||
|
|
||||||
|
// Act: load the store, then remove the task.
|
||||||
|
await objectsStore.loadObjects('user-1');
|
||||||
|
const stateBefore = get(objectsStore);
|
||||||
|
const originalProject = stateBefore.objects.get(projectId);
|
||||||
|
|
||||||
|
await objectsStore.removeObject(taskId);
|
||||||
|
|
||||||
|
// Assert: the task is gone.
|
||||||
|
const stateAfter = get(objectsStore);
|
||||||
|
expect(stateAfter.objects.has(taskId)).toBe(false);
|
||||||
|
|
||||||
|
// Assert: the project's childIds no longer contains the task.
|
||||||
|
const updatedProject = stateAfter.objects.get(projectId);
|
||||||
|
expect(updatedProject.childIds).not.toContain(taskId);
|
||||||
|
|
||||||
|
// Assert: the original project object was NOT mutated.
|
||||||
|
expect(originalProject.childIds).toContain(taskId);
|
||||||
|
expect(updatedProject).not.toBe(originalProject);
|
||||||
|
expect(updatedProject.childIds).not.toBe(originalProject.childIds);
|
||||||
|
|
||||||
|
// Assert: the backend was told to delete the task and persist the parent.
|
||||||
|
expect(objectsService.deleteObject).toHaveBeenCalledWith(taskId);
|
||||||
|
expect(objectsService.updateObject).toHaveBeenCalledWith(projectId, expect.any(String));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to delete the root object', async () => {
|
||||||
|
const rootId = 'root-1';
|
||||||
|
|
||||||
|
usersService.getUser.mockResolvedValue({
|
||||||
|
userId: 'user-1',
|
||||||
|
name: 'Test User',
|
||||||
|
rootObjectId: rootId,
|
||||||
|
objects: [makeRecord(rootId, { type: 'root', childIds: [] })]
|
||||||
|
});
|
||||||
|
|
||||||
|
await objectsStore.loadObjects('user-1');
|
||||||
|
|
||||||
|
await expect(objectsStore.removeObject(rootId)).rejects.toThrow('Cannot delete root object');
|
||||||
|
expect(objectsService.deleteObject).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,25 +1,52 @@
|
|||||||
<script>
|
<script>
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { auth } from '$lib/stores/auth.js';
|
import { auth } from '$lib/stores/auth.js';
|
||||||
|
import { createUser } from '$lib/services/users.js';
|
||||||
|
|
||||||
let userId = $state('');
|
let userId = $state('');
|
||||||
|
let name = $state('');
|
||||||
|
let error = $state('');
|
||||||
|
let creating = $state(false);
|
||||||
|
|
||||||
function enterApp() {
|
async function enterApp() {
|
||||||
if (userId.trim()) {
|
const trimmedUserId = userId.trim();
|
||||||
auth.login(userId.trim());
|
const trimmedName = name.trim() || trimmedUserId;
|
||||||
goto('/dashboard');
|
|
||||||
|
if (!trimmedUserId) return;
|
||||||
|
|
||||||
|
error = '';
|
||||||
|
creating = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createUser(trimmedUserId, trimmedName);
|
||||||
|
} catch (err) {
|
||||||
|
// User may already exist; that's fine for login.
|
||||||
|
console.warn('Create user call failed, proceeding as login:', err);
|
||||||
|
} finally {
|
||||||
|
creating = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auth.login(trimmedUserId);
|
||||||
|
goto('/dashboard');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h1>OwOrganizer</h1>
|
<h1>OwOrganizer</h1>
|
||||||
|
|
||||||
<p>Enter your user ID to open the app.</p>
|
<p>Enter your user ID to open the app. If the user does not exist, it will be created.</p>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p style="color: red">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<form onsubmit={(e) => { e.preventDefault(); enterApp(); }}>
|
<form onsubmit={(e) => { e.preventDefault(); enterApp(); }}>
|
||||||
<label>
|
<label>
|
||||||
User ID
|
User ID
|
||||||
<input type="text" bind:value={userId} placeholder="e.g., alice" />
|
<input type="text" bind:value={userId} placeholder="e.g., alice" />
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Open App</button>
|
<label>
|
||||||
|
Display Name
|
||||||
|
<input type="text" bind:value={name} placeholder="e.g., Alice" />
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={creating}>{creating ? 'Opening...' : 'Open App'}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,49 +1,145 @@
|
|||||||
<script>
|
<script>
|
||||||
import { auth } from '$lib/stores/auth.js';
|
import { auth } from '$lib/stores/auth.js';
|
||||||
import { getObjectsByUser } from '$lib/services/objects.js';
|
import { objectsStore } from '$lib/stores/objects.js';
|
||||||
import Status from '$lib/components/Status.svelte';
|
import Status from '$lib/components/Status.svelte';
|
||||||
|
import ObjectRenderer from '$lib/components/ObjectRenderer.svelte';
|
||||||
|
|
||||||
let userId = $derived($auth);
|
let userId = $derived($auth);
|
||||||
let objects = $state([]);
|
|
||||||
let loading = $state(true);
|
let newType = $state('task');
|
||||||
let error = $state(null);
|
let newText = $state('');
|
||||||
|
let selectedParentId = $state('');
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
loading = true;
|
objectsStore.loadObjects(userId);
|
||||||
getObjectsByUser(userId)
|
|
||||||
.then((data) => {
|
|
||||||
objects = data || [];
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
error = err.message;
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
loading = false;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** @type {import('$lib/services/objectParser.js').TypedObject | undefined} */
|
||||||
|
let rootObject = $derived(
|
||||||
|
$objectsStore.rootId ? $objectsStore.objects.get($objectsStore.rootId) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Event} event
|
||||||
|
*/
|
||||||
|
async function handleCreate(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!userId || !selectedParentId || !newText.trim()) return;
|
||||||
|
|
||||||
|
await objectsStore.addObject(userId, /** @type {'project' | 'task' | 'note'} */ (newType), selectedParentId, newText.trim());
|
||||||
|
newText = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} objectId
|
||||||
|
*/
|
||||||
|
function selectParent(objectId) {
|
||||||
|
selectedParentId = objectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateRootChild(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
selectedParentId = $objectsStore.rootId;
|
||||||
|
if (!userId || !selectedParentId || !newText.trim()) return;
|
||||||
|
|
||||||
|
await objectsStore.addObject(userId, /** @type {'project' | 'task' | 'note'} */ (newType), selectedParentId, newText.trim());
|
||||||
|
newText = '';
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !userId}
|
{#if !userId}
|
||||||
<p>You are not logged in. <a href="/">Go home</a></p>
|
<p>You are not logged in. <a href="/">Go home</a></p>
|
||||||
{:else}
|
{:else}
|
||||||
<h1>Dashboard</h1>
|
<h1>Dashboard</h1>
|
||||||
<p>Welcome, {userId}.</p>
|
<p>Welcome, {userId}.</p>
|
||||||
|
|
||||||
<h2>Your Objects</h2>
|
<h2>Your Objects</h2>
|
||||||
{#if loading}
|
{#if $objectsStore.loading}
|
||||||
<Status message="Loading objects..." />
|
<Status message="Loading objects..." />
|
||||||
{:else if error}
|
{:else if $objectsStore.error}
|
||||||
<p style="color: red">Error: {error}</p>
|
<p style="color: red">Error: {$objectsStore.error}</p>
|
||||||
{:else if objects.length === 0}
|
{:else if !rootObject}
|
||||||
<p>No objects found.</p>
|
<p>No root object found.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ul>
|
<section class="create-form">
|
||||||
{#each objects as obj (obj.objectId)}
|
<h3>Create Object</h3>
|
||||||
<li>
|
<form onsubmit={handleCreate}>
|
||||||
<strong>{obj.objectId}</strong>: {obj.objectData}
|
<label>
|
||||||
</li>
|
Type
|
||||||
{/each}
|
<select bind:value={newType}>
|
||||||
</ul>
|
<option value="project">Project</option>
|
||||||
|
<option value="task">Task</option>
|
||||||
|
<option value="note">Note</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Parent
|
||||||
|
<select bind:value={selectedParentId}>
|
||||||
|
<option value={$objectsStore.rootId}>Root</option>
|
||||||
|
{#each $objectsStore.objects.values() as obj}
|
||||||
|
{#if obj.type !== 'root'}
|
||||||
|
<option value={obj.objectId}>{obj.type}: {obj.type === 'project' ? obj.name : obj.text}</option>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Name / Text
|
||||||
|
<input type="text" bind:value={newText} placeholder="Enter name or text" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit">Create</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="create-form">
|
||||||
|
<h3>Create Object BUT BETTER</h3>
|
||||||
|
<form onsubmit={handleCreateRootChild}>
|
||||||
|
<label>
|
||||||
|
Name / Text
|
||||||
|
<input type="text" bind:value={newText} placeholder="Enter name or text" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Type
|
||||||
|
<select bind:value={newType}>
|
||||||
|
<option value="project">Project</option>
|
||||||
|
<option value="task">Task</option>
|
||||||
|
<option value="note">Note</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit">Create</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ObjectRenderer object={rootObject} />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
|
||||||
|
.create-form {
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -2,5 +2,9 @@ import { sveltekit } from '@sveltejs/kit/vite';
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit()]
|
plugins: [sveltekit()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
include: ['src/**/*.{test,spec}.{js,ts}']
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
### Backend
|
|
||||||
## UserTable
|
|
||||||
1. userId
|
|
||||||
2. rootObjectId
|
|
||||||
|
|
||||||
|
|
||||||
1. userId
|
|
||||||
2. objectId
|
|
||||||
3. objectData - encrypted JSON object
|
|
||||||
|
|
||||||
... that's it
|
|
||||||
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
Objects are JSON objects
|
|
||||||
Can be root idk
|
|
||||||
|
|
||||||
Hats/Views are scopes for other objects (like work, home, etc)
|
|
||||||
|
|
||||||
Projects are parent objects for kanbans/tasks???
|
|
||||||
or not???
|
|
||||||
maybe parents for everything?
|
|
||||||
|
|
||||||
All parent objects also have a default parent. for easy pick up and use.
|
|
||||||
|
|
||||||
children objects:
|
|
||||||
tasks - checkbox object, can have notes
|
|
||||||
notes -
|
|
||||||
Reference in New Issue
Block a user