Profiles
Profiles are the foundation of Aya’s community platform. They represent individuals, organizations, and products with rich metadata, multi-language support, and extensive customization options.
Profile Types
Aya supports three profile kinds:
Individual Personal profiles for community members
Organization Companies, communities, and groups
Product Projects, tools, and software products
Individual Profiles
Each user can have exactly one individual profile . This is created automatically during first login and linked via user.individual_profile_id.
CREATE TABLE " user " (
"id" CHAR ( 26 ) PRIMARY KEY ,
"name" TEXT NOT NULL ,
"email" TEXT ,
"github_handle" TEXT ,
"individual_profile_id" CHAR ( 26 ) REFERENCES "profile"
);
Use cases :
Personal portfolios
Developer profiles
Community member pages
Organization Profiles
Organizations have members with different roles and permissions.
Use cases :
Open source projects
Companies
Community groups
Event organizers
Features :
Team management with membership roles
Profile resources (GitHub repos, links)
Custom pages (About, Team, Projects)
Profile points and gamification
Product Profiles
Products represent software projects, tools, or services.
Use cases :
Open source libraries
SaaS products
Mobile apps
Tools and utilities
Features :
Linked to author profiles
Resource syncing (GitHub repos, package managers)
Version history via stories
Product-specific custom fields in properties JSON
Profile Structure
Core Fields
apps/services/etc/data/default/migrations/0001_initial.sql
CREATE TABLE " profile " (
"id" CHAR ( 26 ) PRIMARY KEY ,
"slug" TEXT NOT NULL UNIQUE ,
"kind" TEXT NOT NULL , -- 'individual', 'organization', 'product'
"custom_domain" TEXT , -- e.g., 'eser.dev'
"profile_picture_uri" TEXT ,
"pronouns" TEXT , -- e.g., 'he/him', 'she/her', 'they/them'
"properties" JSONB, -- Custom metadata (extensible)
"created_at" TIMESTAMP WITH TIME ZONE DEFAULT NOW (),
"updated_at" TIMESTAMP WITH TIME ZONE ,
"deleted_at" TIMESTAMP WITH TIME ZONE , -- Soft delete
"approved_at" TIMESTAMP WITH TIME ZONE -- Moderation approval
);
Translations (i18n)
All user-facing text is stored in the profile_tx (translation) table:
CREATE TABLE " profile_tx " (
"profile_id" CHAR ( 26 ) NOT NULL ,
"locale_code" CHAR ( 12 ) NOT NULL , -- 'en', 'tr', 'pt-PT', etc.
"title" TEXT NOT NULL , -- Display name
"description" TEXT NOT NULL , -- Bio/summary
"properties" JSONB, -- Locale-specific metadata
PRIMARY KEY ( "profile_id" , "locale_code" )
);
3-Tier Fallback : When a profile doesn’t have a translation for the requested locale, Aya falls back to the profile’s default_locale, then to any available locale.
The properties JSONB field allows storing custom data without schema changes:
{
"website" : "https://example.com" ,
"location" : "San Francisco, CA" ,
"company" : "Acme Inc" ,
"hireable" : true ,
"badges" : [ "verified" , "early_adopter" ],
"social" : {
"mastodon" : "@[email protected] "
}
}
Profile Links
Profiles can have multiple external links with OAuth integration:
CREATE TABLE " profile_link " (
"id" CHAR ( 26 ) PRIMARY KEY ,
"profile_id" CHAR ( 26 ) REFERENCES "profile" ,
"kind" TEXT NOT NULL , -- 'github', 'linkedin', 'x', 'youtube', 'custom'
"order" INTEGER NOT NULL , -- Display order
"is_managed" BOOLEAN DEFAULT FALSE, -- Auto-synced from OAuth
"is_verified" BOOLEAN DEFAULT FALSE, -- OAuth verification status
"is_hidden" BOOLEAN DEFAULT FALSE, -- Hidden from public view
"remote_id" TEXT , -- External platform ID
"public_id" TEXT , -- Username/handle
"uri" TEXT , -- Full URL
"title" TEXT NOT NULL , -- Display label
"auth_provider" TEXT , -- OAuth provider name
"auth_access_token" TEXT , -- Encrypted OAuth token
"auth_access_token_expires_at" TIMESTAMP ,
"auth_refresh_token" TEXT ,
"properties" JSONB
);
Supported Link Types
OAuth Integrated
Manual Links
GitHub
Auto-sync repositories
Import README as profile pages
Fetch contribution activity
LinkedIn
Verify professional identity
Import work history
Sync profile updates
SpeakerDeck
Import presentations
Auto-create story posts for talks
X (Twitter)
Display username
Link to profile
YouTube
Channel links
Import video metadata
Custom
Any external URL
Personal websites, blogs, etc.
Link Management Example
Frontend: Creating a GitHub link
import { backend } from "@/modules/backend/backend.ts" ;
// 1. Initiate OAuth flow
const { authUrl } = await backend . initiateProfileLinkOAuth (
locale ,
profileSlug ,
"github"
);
window . location . href = authUrl ;
// 2. After OAuth callback, finalize connection
const { link } = await backend . finalizeGitHubConnection (
locale ,
profileSlug ,
code // from OAuth callback
);
// 3. Link is now created with is_managed=true, is_verified=true
// GitHub repos will auto-sync as profile resources
Profile Pages
Profiles can have custom pages (like “About”, “Projects”, “CV”):
CREATE TABLE " profile_page " (
"id" CHAR ( 26 ) PRIMARY KEY ,
"profile_id" CHAR ( 26 ) REFERENCES "profile" ,
"slug" TEXT NOT NULL , -- URL slug (e.g., 'about', 'cv')
"order" INTEGER NOT NULL , -- Display order in navigation
"cover_picture_uri" TEXT ,
"published_at" TIMESTAMP , -- NULL = draft
UNIQUE ( "profile_id" , "slug" )
);
CREATE TABLE " profile_page_tx " (
"profile_page_id" CHAR ( 26 ),
"locale_code" CHAR ( 12 ),
"title" TEXT NOT NULL ,
"summary" TEXT NOT NULL ,
"content" TEXT NOT NULL , -- Markdown/MDX content
PRIMARY KEY ( "profile_page_id" , "locale_code" )
);
Creating a Profile Page
const page = await backend . createProfilePage ( locale , profileSlug , {
slug: "about" ,
order: 0 ,
title: "About Me" ,
summary: "Learn more about my background and interests" ,
content: `
# About Me
I'm a software engineer passionate about open source...
## Skills
- JavaScript/TypeScript
- Go
- React
` ,
});
Pages are accessible at /{locale}/{profile-slug}/{page-slug} (e.g., /en/eser/about).
Profile Memberships
Organizations and products can have members with roles:
CREATE TABLE " profile_membership " (
"id" CHAR ( 26 ) PRIMARY KEY ,
"profile_id" CHAR ( 26 ) REFERENCES "profile" , -- Organization/product
"member_profile_id" CHAR ( 26 ) REFERENCES "profile" , -- Individual profile
"kind" TEXT NOT NULL , -- 'owner', 'admin', 'member', 'contributor'
"properties" JSONB, -- Role-specific metadata
"started_at" TIMESTAMP , -- When membership began
"finished_at" TIMESTAMP -- When membership ended (optional)
);
Membership Kinds
owner : Full control, can delete profile
admin : Can manage members, edit profile, publish content
member : Can contribute content, participate in discussions
contributor : Limited access, can submit content for review
Example: Adding a Member
const membership = await backend . addProfileMembership (
locale ,
"my-organization" ,
{
memberProfileId: "user-individual-profile-id" ,
kind: "admin" ,
startedAt: new Date (). toISOString (),
}
);
Profile Resources
Resources are external entities linked to a profile (GitHub repos, npm packages, etc.):
CREATE TABLE " profile_resource " (
"id" CHAR ( 26 ) PRIMARY KEY ,
"profile_id" CHAR ( 26 ) REFERENCES "profile" ,
"profile_link_id" CHAR ( 26 ) REFERENCES "profile_link" , -- Source link (optional)
"kind" TEXT NOT NULL , -- 'github_repo', 'npm_package'
"remote_id" TEXT ,
"uri" TEXT ,
"title" TEXT NOT NULL ,
"description" TEXT ,
"properties" JSONB
);
Auto-Syncing GitHub Repos
When a GitHub link is connected with is_managed=true, repos are automatically imported:
// Backend automatically syncs repos when GitHub link is created
const repos = await backend . listGitHubRepos ( locale , profileSlug );
// User can select which repos to add as resources
await backend . createProfileResource ( locale , profileSlug , {
profileLinkId: githubLinkId ,
kind: "github_repo" ,
remoteId: "123456789" ,
uri: "https://github.com/eser/aya.is" ,
title: "aya.is" ,
description: "Open source community platform" ,
properties: {
stars: 142 ,
forks: 28 ,
language: "Go" ,
topics: [ "community" , "open-source" ],
},
});
Profile Permissions
Determining if a user can edit a profile:
Frontend: Checking permissions
const permissions = await backend . getProfilePermissions ( locale , profileSlug );
if ( permissions . canEdit ) {
// Show edit button
}
if ( permissions . canManageMembers ) {
// Show team management
}
if ( permissions . canDelete ) {
// Show delete option
}
Backend: Permission logic (business layer)
package profiles
type Permissions struct {
CanEdit bool
CanManageMembers bool
CanDelete bool
CanPublishStories bool
}
func GetPermissions (
ctx context . Context ,
repo Repository ,
profileID string ,
userID * string ,
) ( * Permissions , error ) {
if userID == nil {
return & Permissions {}, nil // Anonymous user
}
// Check if user owns individual profile
user , _ := repo . GetUserByID ( ctx , * userID )
if user . IndividualProfileID == profileID {
return & Permissions {
CanEdit : true ,
CanDelete : true ,
CanPublishStories : true ,
}, nil
}
// Check membership for organization/product profiles
membership , _ := repo . GetMembership ( ctx , profileID , user . IndividualProfileID )
if membership != nil {
return & Permissions {
CanEdit : membership . Kind == "owner" || membership . Kind == "admin" ,
CanManageMembers : membership . Kind == "owner" || membership . Kind == "admin" ,
CanDelete : membership . Kind == "owner" ,
CanPublishStories : true ,
}, nil
}
return & Permissions {}, nil
}
Business Logic Examples
Get Profile by Slug
pkg/api/business/profiles/get.go
package profiles
import (
" context "
" aya.is/apps/services/pkg/ajan/results "
)
var ErrProfileNotFound = results . ErrNotFound ( "Profile not found" )
func Get (
ctx context . Context ,
repo Repository ,
localeCode string ,
slug string ,
) ( * Profile , error ) {
profile , err := repo . GetBySlug ( ctx , localeCode , slug )
if err != nil {
return nil , err
}
// Business rule: Don't show deleted profiles
if profile . DeletedAt != nil {
return nil , ErrProfileNotFound
}
// Business rule: Only show approved profiles to public
// (Note: In actual code, this would check user permissions)
if profile . ApprovedAt == nil {
return nil , ErrProfileNotFound
}
return profile , nil
}
Create Profile
pkg/api/business/profiles/create.go
package profiles
import (
" context "
" time "
" github.com/segmentio/ksuid "
)
type CreateProfileInput struct {
Slug string
Kind string
Title string
Description string
LocaleCode string
}
func Create (
ctx context . Context ,
repo Repository ,
input CreateProfileInput ,
) ( * Profile , error ) {
// Business rule: Validate slug format
if ! isValidSlug ( input . Slug ) {
return nil , ErrInvalidSlug
}
// Business rule: Check slug availability
exists , _ := repo . SlugExists ( ctx , input . Slug )
if exists {
return nil , ErrSlugTaken
}
// Business rule: Validate kind
if ! isValidKind ( input . Kind ) {
return nil , ErrInvalidKind
}
profile := & Profile {
ID : ksuid . New (). String (),
Slug : input . Slug ,
Kind : input . Kind ,
CreatedAt : time . Now (),
}
translation := & ProfileTranslation {
ProfileID : profile . ID ,
LocaleCode : input . LocaleCode ,
Title : input . Title ,
Description : input . Description ,
}
if err := repo . Create ( ctx , profile , translation ); err != nil {
return nil , err
}
return profile , nil
}
func isValidSlug ( slug string ) bool {
// Business rule: Slugs must be lowercase alphanumeric + hyphens
matched , _ := regexp . MatchString ( `^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$` , slug )
return matched
}
func isValidKind ( kind string ) bool {
return kind == "individual" || kind == "organization" || kind == "product"
}
Frontend Patterns
Profile Route with SSR
src/routes/$locale/$slug/index.tsx
import { createFileRoute } from "@tanstack/react-router" ;
import { useSuspenseQuery } from "@tanstack/react-query" ;
import { profileQueryOptions , profileStoriesQueryOptions } from "@/modules/backend/queries" ;
import { QueryError } from "@/components/query-error" ;
export const Route = createFileRoute ( "/$locale/$slug/" )({
// Prefetch profile for SSR
loader : async ({ params , context }) => {
const { locale , slug } = params ;
// Ensure profile data (needed for head())
const profile = await context . queryClient . ensureQueryData (
profileQueryOptions ( locale , slug )
);
// Prefetch stories (optional, component-only)
await context . queryClient . prefetchQuery (
profileStoriesQueryOptions ( locale , slug )
);
return { locale , slug , profile };
},
// Meta tags from loader data
head : ({ loaderData }) => ({
title: loaderData . profile . title ,
meta: [
{ name: "description" , content: loaderData . profile . description },
{ property: "og:title" , content: loaderData . profile . title },
{ property: "og:image" , content: loaderData . profile . profilePictureUri },
],
}),
errorComponent: QueryError ,
});
function ProfilePage () {
const { locale , slug } = Route . useParams ();
// Read from hydrated cache
const { data : profile } = useSuspenseQuery ( profileQueryOptions ( locale , slug ));
const { data : stories } = useSuspenseQuery ( profileStoriesQueryOptions ( locale , slug ));
return (
< div >
< h1 >{profile. title } </ h1 >
< p >{profile. description } </ p >
< h2 > Stories </ h2 >
{ stories . map (( story ) => (
< StoryCard key = {story. id } story = { story } />
))}
</ div >
);
}
export default ProfilePage ;
src/components/forms/edit-profile-form.tsx
import { useState } from "react" ;
import { useMutation , useQueryClient } from "@tanstack/react-query" ;
import { backend } from "@/modules/backend/backend.ts" ;
import { profileQueryOptions } from "@/modules/backend/queries" ;
type Props = {
locale : string ;
profile : Profile ;
};
export function EditProfileForm ( props : Props ) {
const [ title , setTitle ] = useState ( props . profile . title );
const [ description , setDescription ] = useState ( props . profile . description );
const queryClient = useQueryClient ();
const updateMutation = useMutation ({
mutationFn : () =>
backend . updateProfile ( props . locale , props . profile . slug , {
title ,
description ,
}),
onSuccess : ( updatedProfile ) => {
// Optimistically update cache
queryClient . setQueryData (
profileQueryOptions ( props . locale , props . profile . slug ). queryKey ,
updatedProfile
);
},
});
return (
< form
onSubmit = {(e) => {
e . preventDefault ();
updateMutation . mutate ();
}}
>
< input
type = "text"
value = { title }
onChange = {(e) => setTitle (e.target.value)}
placeholder = "Profile title"
/>
< textarea
value = { description }
onChange = {(e) => setDescription (e.target.value)}
placeholder = "Description"
/>
< button type = "submit" disabled = {updateMutation. isPending } >
{ updateMutation . isPending ? "Saving..." : "Save" }
</ button >
</ form >
);
}
Profile Routing
Profiles use slug-based URLs:
/{locale}/{slug} - Profile home
/{locale}/{slug}/qa - Q&A section
/{locale}/{slug}/settings - Profile settings (authenticated)
/{locale}/{slug}/stories - All stories
/{locale}/{slug}/stories/{story-slug} - Individual story
/{locale}/{slug}/{page-slug} - Custom page
Slug Conflicts : Custom page slugs must not conflict with reserved routes (qa, settings, stories, admin).
Next Steps
Stories Learn about content publishing and story management
Internationalization Understand multi-language support and fallback patterns
Frontend Development Build profile UI with React and TanStack Query
Backend Development Implement profile business logic and adapters