feat: tiered costs feature + admin tier management + tag selection
Build & Push Docker Image / build (push) Successful in 6m52s

- costs: nullable notes (fix create without notes), drop renewalDate
  (schema + API + UI), gate POST/PATCH/DELETE to admin + costs feature
- feature middleware: admin-aware hasFeature + getEntitlements union;
  /auth/me and login return resolved entitlements
- users: tier enum (free/premium/enterprise) in create/update/list,
  admin UI tier select + tier badge
- tags: GET /tags/all, TagInput autocomplete in new/edit tool forms,
  feature suggestions on focus, query invalidation on create/update
- openapi: nullable ToolUpdate urls, ToolUpdate tier fields, listAllTags
- Dockerfile: push-force to drop renewal_date column
This commit is contained in:
opencode
2026-08-02 01:10:47 +02:00
parent cd4efd16f6
commit 01c70085db
27 changed files with 478 additions and 86 deletions
@@ -23,13 +23,14 @@ export function FeatureInput({ value, onChange, placeholder, "data-testid": test
const known: string[] = allFeatures.data ?? [];
const suggestions = value.trim().length >= 1
? known.filter(
(f) =>
f.toLowerCase().includes(value.toLowerCase()) &&
f.toLowerCase() !== value.toLowerCase()
).slice(0, 6)
: [];
const q = value.trim();
const suggestions = known
.filter(
(f) =>
q.length === 0 ||
(f.toLowerCase().includes(q.toLowerCase()) && f.toLowerCase() !== q.toLowerCase()),
)
.slice(0, 6);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
@@ -0,0 +1,83 @@
import { useState, useRef, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { useListAllTags, getListAllTagsQueryKey } from "@workspace/api-client-react";
import { cn } from "@/lib/utils";
interface TagInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
"data-testid"?: string;
}
export function TagInput({ value, onChange, placeholder, className, "data-testid": testId }: TagInputProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const allTags = useListAllTags({
query: {
queryKey: getListAllTagsQueryKey(),
refetchOnMount: "always",
staleTime: 0,
},
});
const known: string[] = allTags.data ?? [];
const q = value.trim();
const suggestions = known
.filter(
(t) =>
q.length === 0 ||
(t.toLowerCase().includes(q.toLowerCase()) && t.toLowerCase() !== q.toLowerCase()),
)
.slice(0, 6);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
return (
<div ref={containerRef} className="relative w-full">
<Input
value={value}
onChange={(e) => {
onChange(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
placeholder={placeholder}
className={className}
data-testid={testId}
autoComplete="off"
/>
{open && suggestions.length > 0 && (
<div className="absolute z-50 top-full mt-1 w-full rounded-md border bg-popover shadow-md text-sm overflow-hidden">
{suggestions.map((s) => (
<button
key={s}
type="button"
className={cn(
"w-full text-left px-3 py-2 hover:bg-muted transition-colors text-foreground",
)}
onMouseDown={(e) => {
e.preventDefault();
onChange(s);
setOpen(false);
}}
data-testid={`suggestion-tag-${s}`}
>
{s}
</button>
))}
</div>
)}
</div>
);
}