Split cart into 4 microservices: relations, likes, orders, page-config→blog
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Phase 1 - Relations service (internal): owns ContainerRelation, exposes get-children data + attach/detach-child actions. Retargeted events, blog, market callers from cart to relations. Phase 2 - Likes service (internal): unified Like model replaces ProductLike and PostLike with generic target_type/target_slug/target_id. Exposes is-liked, liked-slugs, liked-ids data + toggle action. Phase 3 - PageConfig → blog: moved ownership to blog with direct DB queries, removed proxy endpoints from cart. Phase 4 - Orders service (public): owns Order/OrderItem + SumUp checkout flow. Cart checkout now delegates to orders via create-order action. Webhook/return routes and reconciliation moved to orders. Phase 5 - Infrastructure: docker-compose, deploy.sh, Dockerfiles updated for all 3 new services. Added orders_url helper and factory model imports. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,12 @@ COPY federation/__init__.py ./federation/__init__.py
|
||||
COPY federation/models/ ./federation/models/
|
||||
COPY account/__init__.py ./account/__init__.py
|
||||
COPY account/models/ ./account/models/
|
||||
COPY relations/__init__.py ./relations/__init__.py
|
||||
COPY relations/models/ ./relations/models/
|
||||
COPY likes/__init__.py ./likes/__init__.py
|
||||
COPY likes/models/ ./likes/models/
|
||||
COPY orders/__init__.py ./orders/__init__.py
|
||||
COPY orders/models/ ./orders/models/
|
||||
|
||||
# Copy built editor assets from stage 1
|
||||
COPY --from=editor-build /static/scripts/editor.js /static/scripts/editor.css shared/static/scripts/
|
||||
|
||||
@@ -6,14 +6,16 @@ MODELS = [
|
||||
"shared.models.kv",
|
||||
"shared.models.menu_item",
|
||||
"shared.models.menu_node",
|
||||
"shared.models.page_config",
|
||||
"blog.models.snippet",
|
||||
"blog.models.tag_group",
|
||||
]
|
||||
|
||||
TABLES = frozenset({
|
||||
"posts", "authors", "post_authors", "tags", "post_tags", "post_likes",
|
||||
"posts", "authors", "post_authors", "tags", "post_tags",
|
||||
"snippets", "tag_groups", "tag_group_tags",
|
||||
"menu_items", "menu_nodes", "kv",
|
||||
"page_configs",
|
||||
})
|
||||
|
||||
run_alembic(context.config, MODELS, TABLES)
|
||||
|
||||
@@ -31,13 +31,65 @@ def register() -> Blueprint:
|
||||
result = await handler()
|
||||
return jsonify(result or {"ok": True})
|
||||
|
||||
# --- update-page-config (proxy to cart, where page_configs table lives) ---
|
||||
# --- update-page-config ---
|
||||
async def _update_page_config():
|
||||
"""Create or update a PageConfig — proxies to cart service."""
|
||||
from shared.infrastructure.actions import call_action
|
||||
"""Create or update a PageConfig (page_configs now lives in db_blog)."""
|
||||
from shared.models.page_config import PageConfig
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
data = await request.get_json(force=True)
|
||||
return await call_action("cart", "update-page-config", payload=data)
|
||||
container_type = data.get("container_type", "page")
|
||||
container_id = data.get("container_id")
|
||||
if container_id is None:
|
||||
return {"error": "container_id required"}, 400
|
||||
|
||||
pc = (await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == container_type,
|
||||
PageConfig.container_id == container_id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if pc is None:
|
||||
pc = PageConfig(
|
||||
container_type=container_type,
|
||||
container_id=container_id,
|
||||
features=data.get("features", {}),
|
||||
)
|
||||
g.s.add(pc)
|
||||
await g.s.flush()
|
||||
|
||||
if "features" in data:
|
||||
features = dict(pc.features or {})
|
||||
for key, val in data["features"].items():
|
||||
if isinstance(val, bool):
|
||||
features[key] = val
|
||||
elif val in ("true", "1", "on"):
|
||||
features[key] = True
|
||||
elif val in ("false", "0", "off", None):
|
||||
features[key] = False
|
||||
pc.features = features
|
||||
flag_modified(pc, "features")
|
||||
|
||||
if "sumup_merchant_code" in data:
|
||||
pc.sumup_merchant_code = data["sumup_merchant_code"] or None
|
||||
if "sumup_checkout_prefix" in data:
|
||||
pc.sumup_checkout_prefix = data["sumup_checkout_prefix"] or None
|
||||
if "sumup_api_key" in data:
|
||||
pc.sumup_api_key = data["sumup_api_key"] or None
|
||||
|
||||
await g.s.flush()
|
||||
|
||||
return {
|
||||
"id": pc.id,
|
||||
"container_type": pc.container_type,
|
||||
"container_id": pc.container_id,
|
||||
"features": pc.features or {},
|
||||
"sumup_merchant_code": pc.sumup_merchant_code,
|
||||
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
|
||||
"sumup_configured": bool(pc.sumup_api_key),
|
||||
}
|
||||
|
||||
_handlers["update-page-config"] = _update_page_config
|
||||
|
||||
|
||||
@@ -286,17 +286,28 @@ class DBClient:
|
||||
|
||||
rows: List[Post] = list((await self.sess.execute(q)).scalars())
|
||||
|
||||
# Load PageConfigs from cart service (page_configs lives in db_cart)
|
||||
# Load PageConfigs directly (page_configs now lives in db_blog)
|
||||
post_ids = [p.id for p in rows]
|
||||
pc_map: Dict[int, dict] = {}
|
||||
if post_ids:
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
from shared.models.page_config import PageConfig
|
||||
try:
|
||||
raw_pcs = await fetch_data("cart", "page-configs-batch",
|
||||
params={"container_type": "page", "ids": ",".join(str(i) for i in post_ids)})
|
||||
if isinstance(raw_pcs, list):
|
||||
for pc in raw_pcs:
|
||||
pc_map[pc["container_id"]] = pc
|
||||
pc_result = await self.sess.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == "page",
|
||||
PageConfig.container_id.in_(post_ids),
|
||||
)
|
||||
)
|
||||
for pc in pc_result.scalars().all():
|
||||
pc_map[pc.container_id] = {
|
||||
"id": pc.id,
|
||||
"container_type": pc.container_type,
|
||||
"container_id": pc.container_id,
|
||||
"features": pc.features or {},
|
||||
"sumup_merchant_code": pc.sumup_merchant_code,
|
||||
"sumup_api_key": pc.sumup_api_key,
|
||||
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
|
||||
}
|
||||
except Exception:
|
||||
pass # graceful degradation — pages render without features
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import re
|
||||
|
||||
from ..ghost_db import DBClient # adjust import path
|
||||
from sqlalchemy import select
|
||||
from models.ghost_content import PostLike
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
from shared.infrastructure.fragments import fetch_fragment
|
||||
from quart import g
|
||||
|
||||
@@ -68,22 +67,15 @@ async def posts_data(
|
||||
post_ids = [p["id"] for p in posts]
|
||||
|
||||
# Add is_liked field to each post for current user
|
||||
if g.user:
|
||||
# Fetch all likes for this user and these posts in one query
|
||||
liked_posts = await session.execute(
|
||||
select(PostLike.post_id).where(
|
||||
PostLike.user_id == g.user.id,
|
||||
PostLike.post_id.in_(post_ids),
|
||||
PostLike.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
liked_post_ids = {row[0] for row in liked_posts}
|
||||
if g.user and post_ids:
|
||||
liked_ids_list = await fetch_data("likes", "liked-ids", params={
|
||||
"user_id": g.user.id, "target_type": "post",
|
||||
}, required=False) or []
|
||||
liked_post_ids = set(liked_ids_list)
|
||||
|
||||
# Add is_liked to each post
|
||||
for post in posts:
|
||||
post["is_liked"] = post["id"] in liked_post_ids
|
||||
else:
|
||||
# Not logged in - no posts are liked
|
||||
for post in posts:
|
||||
post["is_liked"] = False
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
|
||||
from shared.infrastructure.data_client import DATA_HEADER, fetch_data
|
||||
from shared.infrastructure.data_client import DATA_HEADER
|
||||
from shared.contracts.dtos import dto_to_dict
|
||||
from shared.services.registry import services
|
||||
|
||||
@@ -74,31 +74,112 @@ def register() -> Blueprint:
|
||||
|
||||
_handlers["search-posts"] = _search_posts
|
||||
|
||||
# --- page-config (proxy to cart, where page_configs table lives) ---
|
||||
# --- page-config-ensure ---
|
||||
async def _page_config_ensure():
|
||||
"""Get or create a PageConfig for a container_type + container_id."""
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
container_type = request.args.get("container_type", "page")
|
||||
container_id = request.args.get("container_id", type=int)
|
||||
if container_id is None:
|
||||
return {"error": "container_id required"}, 400
|
||||
|
||||
row = (await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == container_type,
|
||||
PageConfig.container_id == container_id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if row is None:
|
||||
row = PageConfig(
|
||||
container_type=container_type,
|
||||
container_id=container_id,
|
||||
features={},
|
||||
)
|
||||
g.s.add(row)
|
||||
await g.s.flush()
|
||||
|
||||
return {
|
||||
"id": row.id,
|
||||
"container_type": row.container_type,
|
||||
"container_id": row.container_id,
|
||||
}
|
||||
|
||||
_handlers["page-config-ensure"] = _page_config_ensure
|
||||
|
||||
# --- page-config ---
|
||||
async def _page_config():
|
||||
"""Return a single PageConfig by container_type + container_id."""
|
||||
return await fetch_data("cart", "page-config",
|
||||
params={"container_type": request.args.get("container_type", "page"),
|
||||
"container_id": request.args.get("container_id", "")},
|
||||
required=False)
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
ct = request.args.get("container_type", "page")
|
||||
cid = request.args.get("container_id", type=int)
|
||||
if cid is None:
|
||||
return None
|
||||
pc = (await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == ct,
|
||||
PageConfig.container_id == cid,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if not pc:
|
||||
return None
|
||||
return _page_config_dict(pc)
|
||||
|
||||
_handlers["page-config"] = _page_config
|
||||
|
||||
# --- page-config-by-id (proxy to cart) ---
|
||||
# --- page-config-by-id ---
|
||||
async def _page_config_by_id():
|
||||
return await fetch_data("cart", "page-config-by-id",
|
||||
params={"id": request.args.get("id", "")},
|
||||
required=False)
|
||||
"""Return a single PageConfig by its primary key."""
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
pc_id = request.args.get("id", type=int)
|
||||
if pc_id is None:
|
||||
return None
|
||||
pc = await g.s.get(PageConfig, pc_id)
|
||||
if not pc:
|
||||
return None
|
||||
return _page_config_dict(pc)
|
||||
|
||||
_handlers["page-config-by-id"] = _page_config_by_id
|
||||
|
||||
# --- page-configs-batch (proxy to cart) ---
|
||||
# --- page-configs-batch ---
|
||||
async def _page_configs_batch():
|
||||
return await fetch_data("cart", "page-configs-batch",
|
||||
params={"container_type": request.args.get("container_type", "page"),
|
||||
"ids": request.args.get("ids", "")},
|
||||
required=False) or []
|
||||
"""Return PageConfigs for multiple container_ids (comma-separated)."""
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
ct = request.args.get("container_type", "page")
|
||||
ids_raw = request.args.get("ids", "")
|
||||
if not ids_raw:
|
||||
return []
|
||||
ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()]
|
||||
if not ids:
|
||||
return []
|
||||
result = await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == ct,
|
||||
PageConfig.container_id.in_(ids),
|
||||
)
|
||||
)
|
||||
return [_page_config_dict(pc) for pc in result.scalars().all()]
|
||||
|
||||
_handlers["page-configs-batch"] = _page_configs_batch
|
||||
|
||||
return bp
|
||||
|
||||
|
||||
def _page_config_dict(pc) -> dict:
|
||||
"""Serialize PageConfig to a JSON-safe dict."""
|
||||
return {
|
||||
"id": pc.id,
|
||||
"container_type": pc.container_type,
|
||||
"container_id": pc.container_id,
|
||||
"features": pc.features or {},
|
||||
"sumup_merchant_code": pc.sumup_merchant_code,
|
||||
"sumup_api_key": pc.sumup_api_key,
|
||||
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ async def create_menu_item(
|
||||
)
|
||||
session.add(menu_node)
|
||||
await session.flush()
|
||||
await call_action("cart", "attach-child", payload={
|
||||
await call_action("relations", "attach-child", payload={
|
||||
"parent_type": "page", "parent_id": post_id,
|
||||
"child_type": "menu_node", "child_id": menu_node.id,
|
||||
})
|
||||
@@ -134,11 +134,11 @@ async def update_menu_item(
|
||||
await session.flush()
|
||||
|
||||
if post_id is not None and post_id != old_post_id:
|
||||
await call_action("cart", "detach-child", payload={
|
||||
await call_action("relations", "detach-child", payload={
|
||||
"parent_type": "page", "parent_id": old_post_id,
|
||||
"child_type": "menu_node", "child_id": menu_node.id,
|
||||
})
|
||||
await call_action("cart", "attach-child", payload={
|
||||
await call_action("relations", "attach-child", payload={
|
||||
"parent_type": "page", "parent_id": post_id,
|
||||
"child_type": "menu_node", "child_id": menu_node.id,
|
||||
})
|
||||
@@ -154,7 +154,7 @@ async def delete_menu_item(session: AsyncSession, item_id: int) -> bool:
|
||||
|
||||
menu_node.deleted_at = func.now()
|
||||
await session.flush()
|
||||
await call_action("cart", "detach-child", payload={
|
||||
await call_action("relations", "detach-child", payload={
|
||||
"parent_type": "page", "parent_id": menu_node.container_id,
|
||||
"child_type": "menu_node", "child_id": menu_node.id,
|
||||
})
|
||||
|
||||
@@ -22,23 +22,27 @@ def register():
|
||||
@require_admin
|
||||
async def admin(slug: str):
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
# Load features for page admin (page_configs lives in db_cart)
|
||||
# Load features for page admin (page_configs now lives in db_blog)
|
||||
post = (g.post_data or {}).get("post", {})
|
||||
features = {}
|
||||
sumup_configured = False
|
||||
sumup_merchant_code = ""
|
||||
sumup_checkout_prefix = ""
|
||||
if post.get("is_page"):
|
||||
raw_pc = await fetch_data("cart", "page-config",
|
||||
params={"container_type": "page", "container_id": post["id"]},
|
||||
required=False)
|
||||
if raw_pc:
|
||||
features = raw_pc.get("features") or {}
|
||||
sumup_configured = bool(raw_pc.get("sumup_api_key"))
|
||||
sumup_merchant_code = raw_pc.get("sumup_merchant_code") or ""
|
||||
sumup_checkout_prefix = raw_pc.get("sumup_checkout_prefix") or ""
|
||||
pc = (await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == "page",
|
||||
PageConfig.container_id == post["id"],
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if pc:
|
||||
features = pc.features or {}
|
||||
sumup_configured = bool(pc.sumup_api_key)
|
||||
sumup_merchant_code = pc.sumup_merchant_code or ""
|
||||
sumup_checkout_prefix = pc.sumup_checkout_prefix or ""
|
||||
|
||||
ctx = {
|
||||
"features": features,
|
||||
@@ -84,7 +88,7 @@ def register():
|
||||
return jsonify({"error": "Expected JSON object with feature flags."}), 400
|
||||
|
||||
# Update via cart action (page_configs lives in db_cart)
|
||||
result = await call_action("cart", "update-page-config", payload={
|
||||
result = await call_action("blog", "update-page-config", payload={
|
||||
"container_type": "page",
|
||||
"container_id": post_id,
|
||||
"features": body,
|
||||
@@ -129,7 +133,7 @@ def register():
|
||||
if api_key:
|
||||
payload["sumup_api_key"] = api_key
|
||||
|
||||
result = await call_action("cart", "update-page-config", payload=payload)
|
||||
result = await call_action("blog", "update-page-config", payload=payload)
|
||||
|
||||
features = result.get("features", {})
|
||||
html = await render_template(
|
||||
|
||||
@@ -11,8 +11,8 @@ from quart import (
|
||||
request,
|
||||
)
|
||||
from .services.post_data import post_data
|
||||
from .services.post_operations import toggle_post_like
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
from shared.infrastructure.actions import call_action
|
||||
from shared.contracts.dtos import CartSummaryDTO, dto_from_dict
|
||||
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
|
||||
|
||||
@@ -144,11 +144,10 @@ def register():
|
||||
post_id = g.post_data["post"]["id"]
|
||||
user_id = g.user.id
|
||||
|
||||
liked, error = await toggle_post_like(g.s, user_id, post_id)
|
||||
|
||||
if error:
|
||||
resp = make_response(error, 404)
|
||||
return resp
|
||||
result = await call_action("likes", "toggle", payload={
|
||||
"user_id": user_id, "target_type": "post", "target_id": post_id,
|
||||
})
|
||||
liked = result["liked"]
|
||||
|
||||
html = await render_template(
|
||||
"_types/browse/like/button.html",
|
||||
|
||||
@@ -41,7 +41,7 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl
|
||||
if not post.is_page:
|
||||
raise MarketError("Markets can only be created on pages, not posts.")
|
||||
|
||||
raw_pc = await fetch_data("cart", "page-config",
|
||||
raw_pc = await fetch_data("blog", "page-config",
|
||||
params={"container_type": "page", "container_id": post_id},
|
||||
required=False)
|
||||
if raw_pc is None or not (raw_pc.get("features") or {}).get("market"):
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from ...blog.ghost_db import DBClient # adjust import path
|
||||
from sqlalchemy import select
|
||||
from models.ghost_content import PostLike
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
from quart import g
|
||||
|
||||
async def post_data(slug, session, include_drafts=False):
|
||||
@@ -15,14 +14,10 @@ async def post_data(slug, session, include_drafts=False):
|
||||
# Check if current user has liked this post
|
||||
is_liked = False
|
||||
if g.user:
|
||||
liked_record = await session.scalar(
|
||||
select(PostLike).where(
|
||||
PostLike.user_id == g.user.id,
|
||||
PostLike.post_id == post["id"],
|
||||
PostLike.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
is_liked = liked_record is not None
|
||||
liked_data = await fetch_data("likes", "is-liked", params={
|
||||
"user_id": g.user.id, "target_type": "post", "target_id": post["id"],
|
||||
}, required=False)
|
||||
is_liked = (liked_data or {}).get("liked", False)
|
||||
|
||||
# Add is_liked to post dict
|
||||
post["is_liked"] = is_liked
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, func, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.ghost_content import Post, PostLike
|
||||
|
||||
|
||||
async def toggle_post_like(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
post_id: int,
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Toggle a post like for a given user using soft deletes.
|
||||
Returns (liked_state, error_message).
|
||||
- If error_message is not None, an error occurred.
|
||||
- liked_state indicates whether post is now liked (True) or unliked (False).
|
||||
"""
|
||||
|
||||
# Verify post exists
|
||||
post_exists = await session.scalar(
|
||||
select(Post.id).where(Post.id == post_id, Post.deleted_at.is_(None))
|
||||
)
|
||||
if not post_exists:
|
||||
return False, "Post not found"
|
||||
|
||||
# Check if like exists (not deleted)
|
||||
existing = await session.scalar(
|
||||
select(PostLike).where(
|
||||
PostLike.user_id == user_id,
|
||||
PostLike.post_id == post_id,
|
||||
PostLike.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Unlike: soft delete the like
|
||||
await session.execute(
|
||||
update(PostLike)
|
||||
.where(
|
||||
PostLike.user_id == user_id,
|
||||
PostLike.post_id == post_id,
|
||||
PostLike.deleted_at.is_(None),
|
||||
)
|
||||
.values(deleted_at=func.now())
|
||||
)
|
||||
return False, None
|
||||
else:
|
||||
# Like: add a new like
|
||||
new_like = PostLike(
|
||||
user_id=user_id,
|
||||
post_id=post_id,
|
||||
)
|
||||
session.add(new_like)
|
||||
return True, None
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .ghost_content import Post, Author, Tag, PostAuthor, PostTag, PostLike
|
||||
from .ghost_content import Post, Author, Tag, PostAuthor, PostTag
|
||||
from .snippet import Snippet
|
||||
from .tag_group import TagGroup, TagGroupTag
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
from shared.models.ghost_content import ( # noqa: F401
|
||||
Tag, Post, Author, PostAuthor, PostTag, PostLike,
|
||||
Tag, Post, Author, PostAuthor, PostTag,
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
def register_domain_services() -> None:
|
||||
"""Register services for the blog app.
|
||||
|
||||
Blog owns: Post, Tag, Author, PostAuthor, PostTag, PostLike.
|
||||
Blog owns: Post, Tag, Author, PostAuthor, PostTag.
|
||||
Cross-app calls go over HTTP via call_action() / fetch_data().
|
||||
"""
|
||||
from shared.services.registry import services
|
||||
|
||||
Reference in New Issue
Block a user