Manage projects, members and services through the API
/api/v1/projects, /members and /services: create, list, show, update, delete and restore, with the panel's own rules — so the MCP server can load the collective's work, people and offer without anyone retyping them into forms. The MCP server picks the tools up from openapi.yaml. - Relations travel as ids and are replaced whole when sent, left alone when not: a project's technology_ids, service_ids and members with each one's role; a member's technology_ids; a service's technology_ids and project_ids. Record and relations are saved in one transaction. - Publishing stays a permission of its own. Without projects.publish or services.publish, published_at, unpublish_at and is_published are refused — the fields the panel disables for the same people. - MemberPolicy still lets someone edit their own profile. - Uploads accept `for: projects` and `for: members`, landing where the panel's own upload fields put them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -106,6 +106,12 @@ tags:
|
||||
description: Translatable page content
|
||||
- name: Categories
|
||||
description: Translatable page categories
|
||||
- name: Projects
|
||||
description: The collective's work — translatable, with its team and stack
|
||||
- name: Members
|
||||
description: The people in the collective
|
||||
- name: Services
|
||||
description: What the collective offers
|
||||
- name: Technologies
|
||||
description: What the collective uses, and its place on the tech radar
|
||||
- name: Users
|
||||
@@ -1279,6 +1285,366 @@ paths:
|
||||
"204": { description: Deleted }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/v1/projects:
|
||||
get:
|
||||
tags: [Projects]
|
||||
summary: List projects
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/Lang"
|
||||
- $ref: "#/components/parameters/With"
|
||||
- $ref: "#/components/parameters/PerPage"
|
||||
- name: status
|
||||
in: query
|
||||
schema: { type: string, enum: [idea, active, paused, completed, archived] }
|
||||
- name: search
|
||||
in: query
|
||||
description: Matches the title within the active language only.
|
||||
schema: { type: string }
|
||||
- name: trashed
|
||||
in: query
|
||||
schema: { type: boolean }
|
||||
responses:
|
||||
"200":
|
||||
description: Paginated projects
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/ProjectCollection" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
post:
|
||||
tags: [Projects]
|
||||
summary: Create a project
|
||||
description: |
|
||||
Relations are sent as ids and replaced whole when present:
|
||||
`technology_ids`, `service_ids`, and `members` with each one's role.
|
||||
`published_at` and `unpublish_at` require `projects.publish`. For a
|
||||
cover, upload it with `POST /v1/media` and `for: projects`.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/ProjectWrite" }
|
||||
responses:
|
||||
"201":
|
||||
description: Project created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/Project" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"422": { $ref: "#/components/responses/ValidationError" }
|
||||
|
||||
/v1/projects/{project}:
|
||||
parameters:
|
||||
- name: project
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
get:
|
||||
tags: [Projects]
|
||||
summary: Show a project
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/Lang"
|
||||
- $ref: "#/components/parameters/With"
|
||||
responses:
|
||||
"200":
|
||||
description: The project
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/Project" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"404": { description: Not found }
|
||||
patch:
|
||||
tags: [Projects]
|
||||
summary: Update a project
|
||||
description: Partial update. Translatable fields merge into the existing map.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/ProjectWrite" }
|
||||
responses:
|
||||
"200":
|
||||
description: Project updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/Project" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"422": { $ref: "#/components/responses/ValidationError" }
|
||||
delete:
|
||||
tags: [Projects]
|
||||
summary: Soft-delete a project
|
||||
responses:
|
||||
"204": { description: Deleted }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/v1/projects/{project}/restore:
|
||||
post:
|
||||
tags: [Projects]
|
||||
summary: Restore a soft-deleted project
|
||||
parameters:
|
||||
- name: project
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
"200":
|
||||
description: Project restored
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/Project" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/v1/members:
|
||||
get:
|
||||
tags: [Members]
|
||||
summary: List members
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/Lang"
|
||||
- $ref: "#/components/parameters/With"
|
||||
- $ref: "#/components/parameters/PerPage"
|
||||
- name: search
|
||||
in: query
|
||||
description: Matches the name.
|
||||
schema: { type: string }
|
||||
- name: is_public
|
||||
in: query
|
||||
schema: { type: boolean }
|
||||
- name: trashed
|
||||
in: query
|
||||
schema: { type: boolean }
|
||||
responses:
|
||||
"200":
|
||||
description: Paginated members
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/MemberCollection" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
post:
|
||||
tags: [Members]
|
||||
summary: Create a member
|
||||
description: |
|
||||
`links` is a label → URL map. `technology_ids` is replaced whole when
|
||||
present; which projects someone is on is set from the project, where
|
||||
the role belongs. For a photo, upload it with `POST /v1/media` and
|
||||
`for: members`. Someone may edit their own profile without
|
||||
`members.update`.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/MemberWrite" }
|
||||
responses:
|
||||
"201":
|
||||
description: Member created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/Member" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"422": { $ref: "#/components/responses/ValidationError" }
|
||||
|
||||
/v1/members/{member}:
|
||||
parameters:
|
||||
- name: member
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
get:
|
||||
tags: [Members]
|
||||
summary: Show a member
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/Lang"
|
||||
- $ref: "#/components/parameters/With"
|
||||
responses:
|
||||
"200":
|
||||
description: The member
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/Member" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"404": { description: Not found }
|
||||
patch:
|
||||
tags: [Members]
|
||||
summary: Update a member
|
||||
description: Partial update. Translatable fields merge into the existing map.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/MemberWrite" }
|
||||
responses:
|
||||
"200":
|
||||
description: Member updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/Member" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"422": { $ref: "#/components/responses/ValidationError" }
|
||||
delete:
|
||||
tags: [Members]
|
||||
summary: Soft-delete a member
|
||||
responses:
|
||||
"204": { description: Deleted }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/v1/members/{member}/restore:
|
||||
post:
|
||||
tags: [Members]
|
||||
summary: Restore a soft-deleted member
|
||||
parameters:
|
||||
- name: member
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
"200":
|
||||
description: Member restored
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/Member" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/v1/services:
|
||||
get:
|
||||
tags: [Services]
|
||||
summary: List services
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/Lang"
|
||||
- $ref: "#/components/parameters/With"
|
||||
- $ref: "#/components/parameters/PerPage"
|
||||
- name: is_published
|
||||
in: query
|
||||
schema: { type: boolean }
|
||||
- name: search
|
||||
in: query
|
||||
description: Matches the title within the active language only.
|
||||
schema: { type: string }
|
||||
- name: trashed
|
||||
in: query
|
||||
schema: { type: boolean }
|
||||
responses:
|
||||
"200":
|
||||
description: Paginated services
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/ServiceCollection" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
post:
|
||||
tags: [Services]
|
||||
summary: Create a service
|
||||
description: |
|
||||
`technology_ids` and `project_ids` are replaced whole when present.
|
||||
`is_published`, `published_at` and `unpublish_at` require
|
||||
`services.publish`.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/ServiceWrite" }
|
||||
responses:
|
||||
"201":
|
||||
description: Service created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/Service" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"422": { $ref: "#/components/responses/ValidationError" }
|
||||
|
||||
/v1/services/{service}:
|
||||
parameters:
|
||||
- name: service
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
get:
|
||||
tags: [Services]
|
||||
summary: Show a service
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/Lang"
|
||||
- $ref: "#/components/parameters/With"
|
||||
responses:
|
||||
"200":
|
||||
description: The service
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/Service" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"404": { description: Not found }
|
||||
patch:
|
||||
tags: [Services]
|
||||
summary: Update a service
|
||||
description: Partial update. Translatable fields merge into the existing map.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/ServiceWrite" }
|
||||
responses:
|
||||
"200":
|
||||
description: Service updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/Service" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"422": { $ref: "#/components/responses/ValidationError" }
|
||||
delete:
|
||||
tags: [Services]
|
||||
summary: Soft-delete a service
|
||||
responses:
|
||||
"204": { description: Deleted }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/v1/services/{service}/restore:
|
||||
post:
|
||||
tags: [Services]
|
||||
summary: Restore a soft-deleted service
|
||||
parameters:
|
||||
- name: service
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
"200":
|
||||
description: Service restored
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/Service" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/v1/media:
|
||||
post:
|
||||
tags: [Media]
|
||||
@@ -2295,9 +2661,220 @@ components:
|
||||
nullable: true
|
||||
description: Must be after `published_at`. When it arrives the record leaves the site on its own.
|
||||
|
||||
Project:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
locale: { type: string, description: The language the fields below were resolved into. }
|
||||
title: { type: string, nullable: true }
|
||||
slug: { type: string, nullable: true }
|
||||
tagline: { type: string, nullable: true }
|
||||
description: { type: string, nullable: true, description: HTML from the panel's rich editor. }
|
||||
decisions: { type: string, nullable: true, description: HTML. Why the technical choices were made. }
|
||||
postmortem: { type: string, nullable: true, description: HTML. What went wrong and what was learned. }
|
||||
meta_title: { type: string, nullable: true }
|
||||
meta_description: { type: string, nullable: true }
|
||||
cover_image_alt: { type: string, nullable: true }
|
||||
status: { type: string, enum: [idea, active, paused, completed, archived] }
|
||||
is_featured: { type: boolean }
|
||||
needs_help: { type: boolean }
|
||||
demo_url: { type: string, nullable: true, format: uri }
|
||||
cover_image: { type: string, nullable: true, description: The stored path — what a client PATCHes back. }
|
||||
cover_image_media:
|
||||
allOf: [{ $ref: "#/components/schemas/Image" }]
|
||||
nullable: true
|
||||
readOnly: true
|
||||
started_at: { type: string, format: date, nullable: true }
|
||||
ended_at: { type: string, format: date, nullable: true }
|
||||
published_at: { type: string, format: date-time, nullable: true, description: On the site from then on; null keeps it off. }
|
||||
unpublish_at: { type: string, format: date-time, nullable: true }
|
||||
position: { type: integer }
|
||||
client_id: { type: integer, nullable: true }
|
||||
lead_member_id: { type: integer, nullable: true }
|
||||
technology_ids: { type: array, items: { type: integer } }
|
||||
service_ids: { type: array, items: { type: integer } }
|
||||
members:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
name: { type: string }
|
||||
role: { type: string, nullable: true }
|
||||
created_at: { type: string, format: date-time }
|
||||
updated_at: { type: string, format: date-time }
|
||||
deleted_at: { type: string, format: date-time, nullable: true }
|
||||
translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." }
|
||||
|
||||
ProjectWrite:
|
||||
type: object
|
||||
required: [title, slug]
|
||||
properties:
|
||||
title: { $ref: "#/components/schemas/TranslatedField" }
|
||||
slug:
|
||||
allOf: [{ $ref: "#/components/schemas/TranslatedField" }]
|
||||
description: Unique per language.
|
||||
tagline: { $ref: "#/components/schemas/TranslatedField" }
|
||||
description: { $ref: "#/components/schemas/TranslatedField" }
|
||||
decisions: { $ref: "#/components/schemas/TranslatedField" }
|
||||
postmortem: { $ref: "#/components/schemas/TranslatedField" }
|
||||
meta_title: { $ref: "#/components/schemas/TranslatedField" }
|
||||
meta_description: { $ref: "#/components/schemas/TranslatedField" }
|
||||
cover_image_alt: { $ref: "#/components/schemas/TranslatedField" }
|
||||
status: { type: string, enum: [idea, active, paused, completed, archived] }
|
||||
is_featured: { type: boolean }
|
||||
needs_help: { type: boolean }
|
||||
demo_url: { type: string, format: uri, nullable: true }
|
||||
cover_image: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=projects`. }
|
||||
started_at: { type: string, format: date, nullable: true }
|
||||
ended_at: { type: string, format: date, nullable: true }
|
||||
published_at: { type: string, format: date-time, nullable: true, description: Requires `projects.publish`. }
|
||||
unpublish_at: { type: string, format: date-time, nullable: true, description: Requires `projects.publish`. }
|
||||
position: { type: integer, minimum: 0 }
|
||||
client_id: { type: integer, nullable: true }
|
||||
lead_member_id: { type: integer, nullable: true }
|
||||
technology_ids: { type: array, items: { type: integer } }
|
||||
service_ids: { type: array, items: { type: integer } }
|
||||
members:
|
||||
type: array
|
||||
description: The whole team, replacing the previous one.
|
||||
items:
|
||||
type: object
|
||||
required: [id]
|
||||
properties:
|
||||
id: { type: integer }
|
||||
role: { type: string, nullable: true, maxLength: 120 }
|
||||
|
||||
ProjectCollection:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Project" }
|
||||
links: { $ref: "#/components/schemas/PaginationLinks" }
|
||||
meta: { $ref: "#/components/schemas/PaginationMeta" }
|
||||
|
||||
Member:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
locale: { type: string }
|
||||
name: { type: string }
|
||||
slug: { type: string }
|
||||
headline: { type: string, nullable: true }
|
||||
bio: { type: string, nullable: true, description: Plain text. }
|
||||
avatar: { type: string, nullable: true, description: The stored path. }
|
||||
avatar_media:
|
||||
allOf: [{ $ref: "#/components/schemas/Image" }]
|
||||
nullable: true
|
||||
readOnly: true
|
||||
email: { type: string, nullable: true, description: Never shown on the site. }
|
||||
github_username: { type: string, nullable: true }
|
||||
links: { type: object, additionalProperties: { type: string }, description: Label → URL. }
|
||||
is_public: { type: boolean }
|
||||
is_founder: { type: boolean }
|
||||
joined_at: { type: string, format: date, nullable: true }
|
||||
left_at: { type: string, format: date, nullable: true }
|
||||
position: { type: integer }
|
||||
technology_ids: { type: array, items: { type: integer } }
|
||||
projects:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
title: { type: string, nullable: true }
|
||||
role: { type: string, nullable: true }
|
||||
created_at: { type: string, format: date-time }
|
||||
updated_at: { type: string, format: date-time }
|
||||
deleted_at: { type: string, format: date-time, nullable: true }
|
||||
translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." }
|
||||
|
||||
MemberWrite:
|
||||
type: object
|
||||
required: [name, slug]
|
||||
properties:
|
||||
name: { type: string, maxLength: 255 }
|
||||
slug: { type: string, maxLength: 255, pattern: "^[A-Za-z0-9_-]+$" }
|
||||
headline: { $ref: "#/components/schemas/TranslatedField" }
|
||||
bio: { $ref: "#/components/schemas/TranslatedField" }
|
||||
avatar: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=members`. }
|
||||
email: { type: string, format: email, nullable: true }
|
||||
github_username: { type: string, nullable: true, maxLength: 120 }
|
||||
links: { type: object, nullable: true, additionalProperties: { type: string, format: uri } }
|
||||
is_public: { type: boolean }
|
||||
is_founder: { type: boolean }
|
||||
joined_at: { type: string, format: date, nullable: true }
|
||||
left_at: { type: string, format: date, nullable: true }
|
||||
position: { type: integer, minimum: 0 }
|
||||
technology_ids: { type: array, items: { type: integer } }
|
||||
|
||||
MemberCollection:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Member" }
|
||||
links: { $ref: "#/components/schemas/PaginationLinks" }
|
||||
meta: { $ref: "#/components/schemas/PaginationMeta" }
|
||||
|
||||
Service:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
locale: { type: string }
|
||||
title: { type: string, nullable: true }
|
||||
slug: { type: string, nullable: true }
|
||||
tagline: { type: string, nullable: true }
|
||||
description: { type: string, nullable: true, description: Plain text. }
|
||||
meta_title: { type: string, nullable: true }
|
||||
meta_description: { type: string, nullable: true }
|
||||
icon: { type: string, nullable: true }
|
||||
is_featured: { type: boolean }
|
||||
is_published: { type: boolean }
|
||||
published_at: { type: string, format: date-time, nullable: true }
|
||||
unpublish_at: { type: string, format: date-time, nullable: true }
|
||||
position: { type: integer }
|
||||
technology_ids: { type: array, items: { type: integer } }
|
||||
project_ids: { type: array, items: { type: integer } }
|
||||
created_at: { type: string, format: date-time }
|
||||
updated_at: { type: string, format: date-time }
|
||||
deleted_at: { type: string, format: date-time, nullable: true }
|
||||
translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." }
|
||||
|
||||
ServiceWrite:
|
||||
type: object
|
||||
required: [title, slug]
|
||||
properties:
|
||||
title: { $ref: "#/components/schemas/TranslatedField" }
|
||||
slug:
|
||||
allOf: [{ $ref: "#/components/schemas/TranslatedField" }]
|
||||
description: Unique per language.
|
||||
tagline: { $ref: "#/components/schemas/TranslatedField" }
|
||||
description: { $ref: "#/components/schemas/TranslatedField" }
|
||||
meta_title: { $ref: "#/components/schemas/TranslatedField" }
|
||||
meta_description: { $ref: "#/components/schemas/TranslatedField" }
|
||||
icon: { type: string, nullable: true, maxLength: 120, description: "An icon name, e.g. `support`." }
|
||||
is_featured: { type: boolean }
|
||||
position: { type: integer, minimum: 0 }
|
||||
is_published: { type: boolean, description: Requires `services.publish`. }
|
||||
published_at: { type: string, format: date-time, nullable: true, description: Requires `services.publish`. }
|
||||
unpublish_at: { type: string, format: date-time, nullable: true, description: Requires `services.publish`. }
|
||||
technology_ids: { type: array, items: { type: integer } }
|
||||
project_ids: { type: array, items: { type: integer } }
|
||||
|
||||
ServiceCollection:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Service" }
|
||||
links: { $ref: "#/components/schemas/PaginationLinks" }
|
||||
meta: { $ref: "#/components/schemas/PaginationMeta" }
|
||||
|
||||
MediaFor:
|
||||
type: string
|
||||
enum: [pages, technologies]
|
||||
enum: [pages, technologies, projects, members]
|
||||
default: pages
|
||||
description: The kind of record the image is for.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user