eny.space Landingpage
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(dashboard): implement automated PDS provisioning flow

+410 -18
+4 -2
app/actions/auth.ts
··· 26 26 return { error: error.message }; 27 27 } 28 28 29 + const next = (formData.get("next") as string) || "/dashboard"; 29 30 revalidatePath("/", "layout"); 30 - redirect("/dashboard"); 31 + redirect(next); 31 32 } 32 33 33 34 export async function signIn(formData: FormData) { ··· 47 48 return { error: error.message }; 48 49 } 49 50 51 + const next = (formData.get("next") as string) || "/dashboard"; 50 52 revalidatePath("/", "layout"); 51 - redirect("/dashboard"); 53 + redirect(next); 52 54 } 53 55 54 56 export async function signOut() {
+44 -4
app/actions/subscription.ts
··· 6 6 import { headers } from "next/headers"; 7 7 import type { Stripe } from "stripe"; 8 8 9 + function normalizeSlug(value: string) { 10 + return value 11 + .toLowerCase() 12 + .replace(/[^a-z0-9-]/g, "-") 13 + .replace(/^-+/, "") 14 + .replace(/-+$/, "") 15 + .slice(0, 63); 16 + } 17 + 18 + type PdsCheckoutOptions = { 19 + username?: string; 20 + hostname?: string; 21 + disksizeGb?: number; 22 + }; 23 + 9 24 /** 10 25 * Get user's Stripe customer ID from database (minimal storage) 11 26 */ ··· 145 160 /** 146 161 * Create checkout session for new subscription 147 162 */ 148 - export async function createSubscriptionCheckout(priceId: string) { 163 + export async function createSubscriptionCheckout( 164 + priceId: string, 165 + options?: PdsCheckoutOptions, 166 + ) { 149 167 const supabase = await createClient(); 150 168 const { 151 169 data: { user }, ··· 184 202 "http://localhost:3000"; 185 203 186 204 const checkoutSession = await stripe.checkout.sessions.create({ 205 + // Used later in the Stripe webhook to provision the user's PDS 206 + // with user-selected settings. 207 + metadata: (() => { 208 + const fallbackUsername = normalizeSlug(user.email!.split("@")[0] || "pds"); 209 + const pdsUsername = normalizeSlug(options?.username || fallbackUsername); 210 + const pdsDisksizeGb = Number(options?.disksizeGb); 211 + const normalizedDisksize = 212 + Number.isFinite(pdsDisksizeGb) && pdsDisksizeGb > 0 213 + ? String(Math.floor(pdsDisksizeGb)) 214 + : "10"; 215 + 216 + const requestedHostname = (options?.hostname || "").trim(); 217 + const cleanedHostname = requestedHostname 218 + .replace(/^https?:\/\//i, "") 219 + .replace(/\/.*$/, ""); 220 + const pdsHostnameBase = cleanedHostname || `${pdsUsername}.eny.k8s.frx.pub`; 221 + 222 + return { 223 + user_id: user.id, 224 + user_email: user.email!, 225 + pds_username: pdsUsername, 226 + pds_disksize_gb: normalizedDisksize, 227 + pds_hostname_base: pdsHostnameBase, 228 + }; 229 + })(), 187 230 customer: customerId, 188 231 mode: "subscription", 189 232 payment_method_types: ["card"], ··· 195 238 ], 196 239 success_url: `${origin}/dashboard?session_id={CHECKOUT_SESSION_ID}`, 197 240 cancel_url: `${origin}/dashboard`, 198 - metadata: { 199 - user_id: user.id, 200 - }, 201 241 }); 202 242 203 243 return { url: checkoutSession.url };
+30 -2
app/api/pds/service/route.ts
··· 1 1 import { NextResponse } from "next/server"; 2 2 3 - const PDS_SERVICE_URL = "https://k8s-pds.frx.pub/api/v1/service/1"; 3 + import { createClient } from "@/lib/supabase/server"; 4 + 5 + const PDS_API_BASE_URL = "https://k8s-pds.frx.pub/api/v1"; 4 6 5 7 function getMockService() { 6 8 const now = new Date(); ··· 104 106 ); 105 107 } 106 108 107 - const res = await fetch(PDS_SERVICE_URL, { 109 + const supabase = await createClient(); 110 + const { 111 + data: { user }, 112 + } = await supabase.auth.getUser(); 113 + 114 + if (!user) { 115 + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); 116 + } 117 + 118 + const { data: pdsServiceRow } = await supabase 119 + .from("pds_services") 120 + .select("pds_service_id") 121 + .eq("user_id", user.id) 122 + .maybeSingle(); 123 + 124 + const pdsServiceId = pdsServiceRow?.pds_service_id; 125 + 126 + if (!pdsServiceId) { 127 + return NextResponse.json( 128 + { message: "No provisioned PDS found for this user yet" }, 129 + { status: 404 }, 130 + ); 131 + } 132 + 133 + const pdsServiceUrl = `${PDS_API_BASE_URL}/service/${pdsServiceId}`; 134 + 135 + const res = await fetch(pdsServiceUrl, { 108 136 // Ensure this runs server-side only and is not cached aggressively 109 137 cache: "no-store", 110 138 headers: {
+145 -2
app/api/webhooks/route.ts
··· 2 2 3 3 import { NextResponse } from "next/server"; 4 4 5 + import { randomBytes } from "crypto"; 6 + 5 7 import { stripe } from "@/lib/stripe"; 6 8 import { createAdminClient } from "@/lib/supabase/admin"; 7 9 10 + const PDS_API_BASE_URL = "https://k8s-pds.frx.pub/api/v1"; 11 + 12 + function normalizeSlug(value: string) { 13 + return value 14 + .toLowerCase() 15 + .replace(/[^a-z0-9-]/g, "-") 16 + .replace(/^-+/, "") 17 + .replace(/-+$/, "") 18 + .slice(0, 63); 19 + } 20 + 21 + async function provisionPdsForUser({ 22 + userId, 23 + userEmail, 24 + pdsUsername, 25 + pdsHostnameBase, 26 + disksizeGb, 27 + }: { 28 + userId: string; 29 + userEmail: string; 30 + pdsUsername: string; 31 + pdsHostnameBase: string; 32 + disksizeGb: string; 33 + }) { 34 + const apiToken = process.env.PDS_API_TOKEN; 35 + if (!apiToken) { 36 + throw new Error("Missing PDS_API_TOKEN env var"); 37 + } 38 + 39 + const password = randomBytes(16).toString("base64url"); 40 + const hostnameUrl = `https://${pdsHostnameBase}`; 41 + 42 + const disksize = Number(disksizeGb); 43 + 44 + const supabase = createAdminClient(); 45 + 46 + // Idempotency: if we already have a service_id stored, don't redeploy 47 + const { data: existing } = await supabase 48 + .from("pds_services") 49 + .select("pds_service_id,status") 50 + .eq("user_id", userId) 51 + .maybeSingle(); 52 + 53 + if (existing) { 54 + if (existing.pds_service_id) { 55 + return { skipped: true, pds_service_id: existing.pds_service_id }; 56 + } 57 + // If we already deployed but couldn't capture an id, avoid hammering deploy 58 + if (existing.status && existing.status !== "deploy_failed") { 59 + return { skipped: true, pds_service_id: null }; 60 + } 61 + } 62 + 63 + const deployRes = await fetch(`${PDS_API_BASE_URL}/deploy`, { 64 + method: "POST", 65 + headers: { 66 + Accept: "application/json", 67 + "Content-Type": "application/json", 68 + Authorization: `Bearer ${apiToken}`, 69 + }, 70 + body: JSON.stringify({ 71 + username: pdsUsername, 72 + password, 73 + email: userEmail, 74 + hostname: hostnameUrl, 75 + disksize, 76 + }), 77 + }); 78 + 79 + const deployContentType = deployRes.headers.get("content-type") || ""; 80 + const deployBody = deployContentType.includes("application/json") 81 + ? await deployRes.json() 82 + : await deployRes.text(); 83 + 84 + if (!deployRes.ok) { 85 + // Persist failure status for easier debugging 86 + await supabase.from("pds_services").upsert({ 87 + user_id: userId, 88 + hostname: hostnameUrl, 89 + status: "deploy_failed", 90 + }); 91 + throw new Error( 92 + `PDS deploy failed (${deployRes.status}): ${ 93 + typeof deployBody === "string" ? deployBody : JSON.stringify(deployBody) 94 + }`, 95 + ); 96 + } 97 + 98 + const maybeServiceId = 99 + (typeof deployBody === "object" && deployBody !== null 100 + ? ((deployBody as any).service_id ?? 101 + (deployBody as any).id ?? 102 + (deployBody as any).service?.id ?? 103 + (deployBody as any).data?.id) 104 + : undefined) ?? null; 105 + 106 + const pds_service_id = 107 + typeof maybeServiceId === "string" || typeof maybeServiceId === "number" 108 + ? Number(maybeServiceId) 109 + : null; 110 + 111 + await supabase.from("pds_services").upsert({ 112 + user_id: userId, 113 + pds_service_id, 114 + hostname: hostnameUrl, 115 + status: pds_service_id ? "provisioning" : "deploy_succeeded_no_id", 116 + }); 117 + 118 + return { skipped: false, pds_service_id }; 119 + } 120 + 8 121 export async function POST(req: Request) { 9 122 let event: Stripe.Event; 10 123 ··· 12 125 event = stripe.webhooks.constructEvent( 13 126 await (await req.blob()).text(), 14 127 req.headers.get("stripe-signature") as string, 15 - process.env.STRIPE_WEBHOOK_SECRET as string 128 + process.env.STRIPE_WEBHOOK_SECRET as string, 16 129 ); 17 130 } catch (err) { 18 131 const errorMessage = err instanceof Error ? err.message : "Unknown error"; 19 132 console.log(`❌ Webhook Error: ${errorMessage}`); 20 133 return NextResponse.json( 21 134 { message: `Webhook Error: ${errorMessage}` }, 22 - { status: 400 } 135 + { status: 400 }, 23 136 ); 24 137 } 25 138 ··· 33 146 34 147 if (session.mode === "subscription" && session.customer) { 35 148 const userId = session.metadata?.user_id; 149 + const userEmail = session.metadata?.user_email; 36 150 const customerId = 37 151 typeof session.customer === "string" 38 152 ? session.customer ··· 49 163 console.error("Error storing customer ID:", error); 50 164 } else { 51 165 console.log(`✅ Customer ID stored for user ${userId}`); 166 + } 167 + 168 + // Next step: provision the user's PDS 169 + if (userEmail) { 170 + const fallbackUsername = normalizeSlug(userEmail.split("@")[0] || "pds"); 171 + const pdsUsername = normalizeSlug( 172 + session.metadata?.pds_username || fallbackUsername, 173 + ); 174 + const pdsHostnameBase = 175 + session.metadata?.pds_hostname_base || 176 + `${pdsUsername}.eny.k8s.frx.pub`; 177 + const disksizeGb = session.metadata?.pds_disksize_gb || "10"; 178 + 179 + try { 180 + console.log(`✅ Provisioning PDS for user ${userId}...`); 181 + await provisionPdsForUser({ 182 + userId, 183 + userEmail, 184 + pdsUsername, 185 + pdsHostnameBase, 186 + disksizeGb, 187 + }); 188 + } catch (e) { 189 + console.error(`❌ Provisioning PDS failed for ${userId}:`, e); 190 + } 191 + } else { 192 + console.warn( 193 + `⚠️ Missing user_email metadata in checkout session for user ${userId}.`, 194 + ); 52 195 } 53 196 } 54 197 }
+20 -1
app/components/pricing/pricing-section.tsx
··· 9 9 import { Paragraph } from "@/components/paragraph"; 10 10 11 11 type PricingPlan = { 12 + key: string; 12 13 name: string; 13 14 price: string; 14 15 period: string; 15 16 badge?: string; 16 17 description: string; 17 18 highlight?: boolean; 19 + pdsDiskSizeGb: number; 18 20 features: string[]; 19 21 }; 20 22 21 23 const PLANS: PricingPlan[] = [ 22 24 { 25 + key: "starter", 23 26 name: "Starter plan", 24 27 price: "$19", 25 28 period: "per month", 26 29 description: "Perfect for small projects.", 30 + pdsDiskSizeGb: 10, 27 31 features: [ 28 32 "1 GB storage", 29 33 "5 app deployments", ··· 32 36 ], 33 37 }, 34 38 { 39 + key: "growth", 35 40 name: "Growth plan", 36 41 price: "$49", 37 42 period: "per month", 38 43 badge: "Popular", 39 44 description: "Scale without limits.", 40 45 highlight: true, 46 + pdsDiskSizeGb: 50, 41 47 features: [ 42 48 "10 GB storage", 43 49 "Unlimited app deployments", ··· 46 52 ], 47 53 }, 48 54 { 55 + key: "pro", 49 56 name: "Pro plan", 50 57 price: "$99", 51 58 period: "per month", 52 59 description: "Enterprise‑level performance.", 60 + pdsDiskSizeGb: 200, 53 61 features: [ 54 62 "Unlimited storage", 55 63 "Custom domain support", ··· 81 89 82 90 <div className="mx-auto mt-12 grid max-w-6xl gap-6 md:grid-cols-3"> 83 91 {PLANS.map((plan) => ( 92 + (() => { 93 + const params = new URLSearchParams({ 94 + auto_checkout: "1", 95 + pds_plan: plan.key, 96 + pds_disksize_gb: String(plan.pdsDiskSizeGb), 97 + }); 98 + const signupHref = `/signup?${params.toString()}`; 99 + 100 + return ( 84 101 <Card 85 102 key={plan.name} 86 103 className={[ ··· 118 135 119 136 <CardContent className="mt-6 flex flex-1 flex-col gap-6 px-0"> 120 137 <ButtonLink 121 - href="/signup" 138 + href={signupHref} 122 139 className={[ 123 140 "w-full rounded-full px-4 py-3 text-center text-sm font-semibold uppercase tracking-wide transition", 124 141 plan.highlight ··· 155 172 </div> 156 173 </CardContent> 157 174 </Card> 175 + ); 176 + })() 158 177 ))} 159 178 </div> 160 179 </section>
+50 -2
app/dashboard/dashboard-client.tsx
··· 1 1 "use client"; 2 2 3 - import { useState } from "react"; 3 + import { useEffect, useMemo, useRef, useState } from "react"; 4 4 import { 5 5 createSubscriptionCheckout, 6 6 cancelSubscription, ··· 15 15 subscribed: boolean; 16 16 subscription: any; 17 17 priceId: string; 18 + autoCheckoutFromPlan?: boolean; 19 + pdsPlan?: string; 20 + pdsUsername?: string; 21 + pdsHostname?: string; 22 + pdsDisksizeGb?: string; 18 23 } 19 24 20 25 export default function DashboardClient({ 21 26 subscribed, 22 27 subscription, 23 28 priceId, 29 + autoCheckoutFromPlan, 30 + pdsPlan, 31 + pdsUsername, 32 + pdsHostname, 33 + pdsDisksizeGb, 24 34 }: DashboardClientProps) { 25 35 const [loading, setLoading] = useState(false); 26 36 const [actionLoading, setActionLoading] = useState<string | null>(null); 37 + const hasAutoStartedCheckout = useRef(false); 38 + 39 + const planBasedDisksize = useMemo(() => { 40 + if (pdsDisksizeGb && Number(pdsDisksizeGb) > 0) { 41 + return Number(pdsDisksizeGb); 42 + } 43 + 44 + if (pdsPlan === "growth") return 50; 45 + if (pdsPlan === "pro") return 200; 46 + return 10; 47 + }, [pdsDisksizeGb, pdsPlan]); 48 + 49 + const selectedUsername = pdsUsername || undefined; 50 + const selectedHostname = pdsHostname || undefined; 27 51 28 52 const handleSubscribe = async () => { 29 53 if (!priceId) { ··· 35 59 36 60 setLoading(true); 37 61 try { 38 - const { url } = await createSubscriptionCheckout(priceId); 62 + const { url } = await createSubscriptionCheckout(priceId, { 63 + username: selectedUsername, 64 + hostname: selectedHostname, 65 + disksizeGb: planBasedDisksize, 66 + }); 39 67 if (url) { 40 68 window.location.href = url; 41 69 } ··· 46 74 setLoading(false); 47 75 } 48 76 }; 77 + 78 + useEffect(() => { 79 + if ( 80 + autoCheckoutFromPlan && 81 + !subscribed && 82 + !loading && 83 + !hasAutoStartedCheckout.current 84 + ) { 85 + hasAutoStartedCheckout.current = true; 86 + void handleSubscribe(); 87 + } 88 + }, [autoCheckoutFromPlan, subscribed, loading]); 49 89 50 90 const handleServerCall = async (endpoint: string) => { 51 91 try { ··· 81 121 <Paragraph className="text-sm text-white/80"> 82 122 You need an active subscription to access the server features. 83 123 </Paragraph> 124 + {(pdsPlan || selectedHostname || selectedUsername) && ( 125 + <Paragraph className="text-xs text-white/70"> 126 + Selected plan settings: plan={pdsPlan || "starter"}, disksize= 127 + {planBasedDisksize}GiB 128 + {selectedHostname ? `, hostname=${selectedHostname}` : ""} 129 + {selectedUsername ? `, username=${selectedUsername}` : ""} 130 + </Paragraph> 131 + )} 84 132 <Button 85 133 onClick={handleSubscribe} 86 134 disabled={loading}
+16 -1
app/dashboard/page.tsx
··· 14 14 import DashboardClient from "./dashboard-client"; 15 15 import { ServiceDetailsClient } from "./service-details-client"; 16 16 17 - export default async function DashboardPage() { 17 + type DashboardPageProps = { 18 + searchParams?: { 19 + auto_checkout?: string; 20 + pds_plan?: string; 21 + pds_username?: string; 22 + pds_hostname?: string; 23 + pds_disksize_gb?: string; 24 + }; 25 + }; 26 + 27 + export default async function DashboardPage({ searchParams }: DashboardPageProps) { 18 28 const supabase = await createClient(); 19 29 const { 20 30 data: { user }, ··· 107 117 subscribed={subscribed} 108 118 subscription={subscription} 109 119 priceId={process.env.NEXT_PUBLIC_STRIPE_PRICE_ID || ""} 120 + autoCheckoutFromPlan={searchParams?.auto_checkout === "1"} 121 + pdsPlan={searchParams?.pds_plan} 122 + pdsUsername={searchParams?.pds_username} 123 + pdsHostname={searchParams?.pds_hostname} 124 + pdsDisksizeGb={searchParams?.pds_disksize_gb} 110 125 /> 111 126 </section> 112 127 </CardContent>
+33 -2
app/login/page.tsx
··· 12 12 import { Input } from "@/actions/components/ui/input"; 13 13 import { Label } from "@/actions/components/ui/label"; 14 14 15 - export default function LoginPage() { 15 + type LoginPageProps = { 16 + searchParams?: { 17 + auto_checkout?: string; 18 + pds_plan?: string; 19 + pds_username?: string; 20 + pds_hostname?: string; 21 + pds_disksize_gb?: string; 22 + }; 23 + }; 24 + 25 + export default function LoginPage({ searchParams }: LoginPageProps) { 26 + const nextParams = new URLSearchParams(); 27 + if (searchParams?.auto_checkout) { 28 + nextParams.set("auto_checkout", searchParams.auto_checkout); 29 + } 30 + if (searchParams?.pds_plan) { 31 + nextParams.set("pds_plan", searchParams.pds_plan); 32 + } 33 + if (searchParams?.pds_username) { 34 + nextParams.set("pds_username", searchParams.pds_username); 35 + } 36 + if (searchParams?.pds_hostname) { 37 + nextParams.set("pds_hostname", searchParams.pds_hostname); 38 + } 39 + if (searchParams?.pds_disksize_gb) { 40 + nextParams.set("pds_disksize_gb", searchParams.pds_disksize_gb); 41 + } 42 + 43 + const next = `/dashboard${nextParams.toString() ? `?${nextParams.toString()}` : ""}`; 44 + const signupHref = `/signup${nextParams.toString() ? `?${nextParams.toString()}` : ""}`; 45 + 16 46 return ( 17 47 <main className="flex min-h-[60vh] items-center justify-center px-4"> 18 48 <Card className="w-full max-w-sm"> ··· 24 54 </CardHeader> 25 55 <CardContent> 26 56 <form action={signIn} className="space-y-4"> 57 + <input type="hidden" name="next" value={next} /> 27 58 <div className="space-y-2"> 28 59 <Label htmlFor="email">Email</Label> 29 60 <Input ··· 52 83 <CardFooter className="flex justify-center text-sm text-muted-foreground"> 53 84 <span> 54 85 Don&apos;t have an account?{" "} 55 - <Link href="/signup" className="underline underline-offset-4"> 86 + <Link href={signupHref} className="underline underline-offset-4"> 56 87 Sign up 57 88 </Link> 58 89 </span>
+33 -2
app/signup/page.tsx
··· 12 12 import { Input } from "@/actions/components/ui/input"; 13 13 import { Label } from "@/actions/components/ui/label"; 14 14 15 - export default function SignUpPage() { 15 + type SignUpPageProps = { 16 + searchParams?: { 17 + auto_checkout?: string; 18 + pds_plan?: string; 19 + pds_username?: string; 20 + pds_hostname?: string; 21 + pds_disksize_gb?: string; 22 + }; 23 + }; 24 + 25 + export default function SignUpPage({ searchParams }: SignUpPageProps) { 26 + const nextParams = new URLSearchParams(); 27 + if (searchParams?.auto_checkout) { 28 + nextParams.set("auto_checkout", searchParams.auto_checkout); 29 + } 30 + if (searchParams?.pds_plan) { 31 + nextParams.set("pds_plan", searchParams.pds_plan); 32 + } 33 + if (searchParams?.pds_username) { 34 + nextParams.set("pds_username", searchParams.pds_username); 35 + } 36 + if (searchParams?.pds_hostname) { 37 + nextParams.set("pds_hostname", searchParams.pds_hostname); 38 + } 39 + if (searchParams?.pds_disksize_gb) { 40 + nextParams.set("pds_disksize_gb", searchParams.pds_disksize_gb); 41 + } 42 + 43 + const next = `/dashboard${nextParams.toString() ? `?${nextParams.toString()}` : ""}`; 44 + const loginHref = `/login${nextParams.toString() ? `?${nextParams.toString()}` : ""}`; 45 + 16 46 return ( 17 47 <main className="flex min-h-[60vh] items-center justify-center px-4"> 18 48 <Card className="w-full max-w-sm"> ··· 24 54 </CardHeader> 25 55 <CardContent> 26 56 <form action={signUp} className="space-y-4"> 57 + <input type="hidden" name="next" value={next} /> 27 58 <div className="space-y-2"> 28 59 <Label htmlFor="email">Email</Label> 29 60 <Input ··· 53 84 <CardFooter className="flex justify-center text-sm text-muted-foreground"> 54 85 <span> 55 86 Already have an account?{" "} 56 - <Link href="/login" className="underline underline-offset-4"> 87 + <Link href={loginHref} className="underline underline-offset-4"> 57 88 Login 58 89 </Link> 59 90 </span>
+35
supabase/migrations/006_add_pds_services.sql
··· 1 + -- Track which PDS service has been provisioned for each user 2 + -- This lets the dashboard fetch the correct service via GET /service/{id} 3 + CREATE TABLE IF NOT EXISTS pds_services ( 4 + user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, 5 + pds_service_id BIGINT, 6 + hostname TEXT, 7 + status TEXT NOT NULL DEFAULT 'provisioning', 8 + created_at TIMESTAMPTZ DEFAULT NOW(), 9 + updated_at TIMESTAMPTZ DEFAULT NOW() 10 + ); 11 + 12 + ALTER TABLE pds_services ENABLE ROW LEVEL SECURITY; 13 + 14 + -- Users can see only their own provisioned PDS 15 + DROP POLICY IF EXISTS "Users can view own pds services" ON pds_services; 16 + CREATE POLICY "Users can view own pds services" 17 + ON pds_services 18 + FOR SELECT 19 + USING (auth.uid() = user_id); 20 + 21 + -- Keep updated_at current 22 + CREATE OR REPLACE FUNCTION update_pds_services_updated_at_column() 23 + RETURNS TRIGGER AS $$ 24 + BEGIN 25 + NEW.updated_at = NOW(); 26 + RETURN NEW; 27 + END; 28 + $$ language 'plpgsql'; 29 + 30 + DROP TRIGGER IF EXISTS update_pds_services_updated_at ON pds_services; 31 + CREATE TRIGGER update_pds_services_updated_at 32 + BEFORE UPDATE ON pds_services 33 + FOR EACH ROW 34 + EXECUTE FUNCTION update_pds_services_updated_at_column(); 35 +