lots more stuff

This commit is contained in:
2026-07-16 19:24:39 -07:00
parent a06f21a5c2
commit 228e2e99f7
22 changed files with 1605 additions and 405 deletions
+216
View File
@@ -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
View File
@@ -81,6 +81,7 @@ func createTables(db *sql.DB) error {
"users": `
CREATE TABLE IF NOT EXISTS users (
userId VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
rootObjectId VARCHAR(255) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`,
@@ -109,167 +110,3 @@ func createTables(db *sql.DB) error {
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
}
+1
View File
@@ -21,6 +21,7 @@ require (
github.com/go-playground/validator/v10 v10.30.3 // indirect
github.com/goccy/go-json v0.10.6 // 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/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
+2
View File
@@ -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/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
-162
View File
@@ -62,165 +62,3 @@ func main() {
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"})
}
+197
View File
@@ -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"})
}
+71
View File
@@ -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
View File
@@ -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"})
}