refactor: organize frontend by feature
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-04-25 01:05:50 -04:00
parent b6eb692c27
commit 121757546a
60 changed files with 107 additions and 183 deletions

View File

@@ -1,300 +0,0 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useClient } from '@/plugins/api.js';
import { useSessionStorage } from '@vueuse/core';
import { jwtDecode } from 'jwt-decode';
import { formatDuration } from '@/internal_time_ago.js';
export const useAuthStore = defineStore('auth', () => {
const clientApi = useClient();
const router = useRouter();
const isRefreshing = ref(false);
let refreshPromise = null;
const accessToken = useSessionStorage('auth-accessToken', undefined);
const refreshToken = useSessionStorage('auth-refreshToken', undefined);
const tokenClaims = useSessionStorage('auth-tokenClaims', null, {
serializer: {
read: v => (v ? JSON.parse(v) : null),
write: v => (v ? JSON.stringify(v) : null),
},
});
const isAuthenticated = computed(() => !!accessToken.value);
const userId = computed(() => tokenClaims.value?.sub);
const userRoles = computed(() => {
const claims = tokenClaims.value ?? {};
const candidates = [
claims.role,
claims.roles,
claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/role'],
].flatMap(value => Array.isArray(value) ? value : value ? [value] : []);
return [...new Set(candidates)];
});
const persona = computed(() => tokenClaims.value?.persona ?? null);
const isManager = computed(() => userRoles.value.includes('Administrator') || userRoles.value.includes('Manager'));
const isClient = computed(() => userRoles.value.includes('Client'));
const isProvider = computed(() => userRoles.value.includes('Provider'));
function updateTokens(data) {
if (!data?.accessToken || !data?.refreshToken) {
throw new Error('Invalid token data');
}
accessToken.value = data.accessToken;
refreshToken.value = data.refreshToken;
const claims = getClaimsFromToken(data.accessToken);
tokenClaims.value = claims;
console.log('Tokens updated, user ID:', claims?.sub);
}
function cleanTokens() {
console.log('cleanTokens called - clearing stored tokens');
accessToken.value = undefined;
refreshToken.value = undefined;
tokenClaims.value = null;
}
async function logout() {
cleanTokens();
await router.push('/');
}
async function login(email, password) {
console.log('login called with email:', email);
if (!email || !password) {
throw new Error('Email and password are required');
}
try {
const response = await clientApi.post('api/users/login', {
email: email.trim(),
password: password,
});
if (!response.data?.accessToken || !response.data?.refreshToken) {
throw new Error('Invalid login response');
}
updateTokens(response.data);
console.log('login successful');
return true;
} catch (error) {
console.error('Login failed:', error);
cleanTokens();
throw error;
}
}
async function loginWithGoogle(accessTokenParam) {
console.log('loginWithGoogle called');
if (!accessTokenParam) {
throw new Error('Google access token is required');
}
try {
const response = await clientApi.post('api/users/login-with-google', {
token: accessTokenParam,
});
if (!response.data?.accessToken || !response.data?.refreshToken) {
throw new Error('Invalid Google login response');
}
updateTokens(response.data);
console.log('Google login successful');
return true;
} catch (error) {
console.error('Google login failed:', error);
cleanTokens();
throw error;
}
}
async function loginWithFacebook(authResponse) {
console.log('loginWithFacebook called');
if (!authResponse?.accessToken) {
throw new Error('Facebook access token is required');
}
try {
const response = await clientApi.post('api/users/login-with-facebook', {
token: authResponse.accessToken,
});
if (!response.data?.accessToken || !response.data?.refreshToken) {
throw new Error('Invalid Facebook login response');
}
updateTokens(response.data);
console.log('Facebook login successful');
return true;
} catch (error) {
console.error('Facebook login failed:', error);
cleanTokens();
throw error;
}
}
async function refresh() {
console.log('refresh called');
if (!refreshToken.value) {
cleanTokens(); // Clear tokens first
throw new Error('No refresh token available');
}
if (isRefreshing.value && refreshPromise) {
console.log('Already refreshing, returning existing refreshPromise');
return refreshPromise;
}
try {
isRefreshing.value = true;
refreshPromise = (async () => {
try {
console.log('Sending refresh request...');
const response = await clientApi.post('api/users/refresh', {
refreshToken: refreshToken.value,
});
if (!response.data?.accessToken || !response.data?.refreshToken) {
throw new Error('Invalid refresh response');
}
updateTokens({
accessToken: response.data.accessToken,
refreshToken: response.data.refreshToken,
});
console.log('Token refresh successful');
return true;
} catch (error) {
console.error('Token refresh failed:', error);
cleanTokens();
const currentRoute = router.currentRoute.value;
const returnUrl = currentRoute.fullPath;
// Handle navigation
router
.push({
name: 'login',
query: { returnUrl },
})
.catch(navError => {
console.error('Navigation error after token refresh failure:', navError);
});
throw error; // Re-throw to notify callers
}
})();
return await refreshPromise;
} catch (error) {
throw error;
} finally {
// Ensure these are always reset, even if an error is thrown
isRefreshing.value = false;
refreshPromise = null;
}
}
function getClaimsFromToken(token) {
if (!token) return null;
try {
return jwtDecode(token);
} catch (error) {
console.error('Failed to decode token:', error);
return null;
}
}
function isTokenExpiringSoon(token) {
if (!token) {
console.log('No token provided, considered expiring soon');
return true;
}
const claims = getClaimsFromToken(token);
if (!claims || !claims.exp) {
console.log('No valid claims found, considered expiring soon');
return true;
}
const expirationTime = claims.exp * 1000; // Convert to milliseconds
const currentTime = Date.now();
const fiveMinutesInMs = 2 * 60 * 1000; // 2 minutes for demonstration
// Calculate time remaining (can be negative if already expired)
const timeRemainingMs = expirationTime - currentTime;
// Token is expiring soon if less than 2 minutes remaining or already expired
const isExpiring = timeRemainingMs < fiveMinutesInMs;
// Determine the sign for display purposes
const formattedTimeRemaining =
timeRemainingMs < 0 ? `-${formatDuration(Math.abs(timeRemainingMs))}` : formatDuration(timeRemainingMs);
if (isExpiring) {
console.log(`Token expiration check; is token expired: ${isExpiring}`, {
expirationTime: new Date(expirationTime).toLocaleString(),
currentTime: new Date(currentTime).toLocaleString(),
timeRemaining: formattedTimeRemaining,
});
}
return isExpiring;
}
async function changePassword(newPassword) {
console.log('changePassword called');
if (!isAuthenticated.value) {
throw new Error('User must be authenticated to change password');
}
if (!newPassword) {
throw new Error('New password is required');
}
try {
const response = await clientApi.post('api/users/set-password', {
newPassword,
});
console.log('Password changed successfully');
return true;
} catch (error) {
console.error('Password change failed:', error);
throw error;
}
}
function hasAnyRole(roles) {
return roles.some(role => userRoles.value.includes(role));
}
return {
accessToken,
refreshToken,
isAuthenticated,
userId,
userRoles,
persona,
hasAnyRole,
isManager,
isClient,
isProvider,
isRefreshing,
login,
loginWithGoogle,
loginWithFacebook,
logout,
refresh,
isTokenExpiringSoon,
changePassword,
};
});

View File

@@ -1,122 +0,0 @@
import { computed } from 'vue';
import { defineStore } from 'pinia';
import { useSessionStorage } from '@vueuse/core';
import { useWorkspaceStore } from '@/stores/workspaceStore.js';
import { useContentItemsStore } from '@/stores/contentItemsStore.js';
export const useChannelsStore = defineStore('channels', () => {
const workspaceStore = useWorkspaceStore();
const contentItemsStore = useContentItemsStore();
const customChannelsByWorkspace = useSessionStorage('workspace-custom-channels', {}, {
serializer: {
read: value => (value ? JSON.parse(value) : {}),
write: value => JSON.stringify(value ?? {}),
},
});
const channels = computed(() => {
const currentWorkspaceId = workspaceStore.activeWorkspaceId;
if (!currentWorkspaceId) {
return [];
}
const derivedChannels = new Map();
const customChannels = customChannelsByWorkspace.value[currentWorkspaceId] ?? [];
for (const item of contentItemsStore.items) {
for (const name of parseTargets(item.publicationTargets)) {
const key = slugify(name);
const existing = derivedChannels.get(key) ?? {
id: key,
name,
network: null,
source: 'derived',
};
derivedChannels.set(key, existing);
}
}
for (const channel of customChannels) {
derivedChannels.set(channel.id, {
...channel,
source: 'custom',
});
}
return [...derivedChannels.values()].sort((left, right) => left.name.localeCompare(right.name));
});
const availableNetworks = [
'Instagram',
'TikTok',
'Facebook',
'LinkedIn',
'YouTube',
'X',
'Reddit',
'Website',
];
function createChannel(payload) {
const currentWorkspaceId = workspaceStore.activeWorkspaceId;
if (!currentWorkspaceId) {
throw new Error('An active workspace is required to create a channel.');
}
const normalizedName = payload.name.trim();
const normalizedNetwork = payload.network.trim();
if (!normalizedName) {
throw new Error('Channel name is required.');
}
if (!normalizedNetwork) {
throw new Error('Network is required.');
}
if (!availableNetworks.includes(normalizedNetwork)) {
throw new Error('Selected network is invalid.');
}
const existing = channels.value.some(channel =>
channel.name.toLowerCase() === normalizedName.toLowerCase()
&& (channel.network ?? '').toLowerCase() === normalizedNetwork.toLowerCase()
);
if (existing) {
throw new Error('A channel with this name already exists for the selected network.');
}
const next = customChannelsByWorkspace.value[currentWorkspaceId] ?? [];
customChannelsByWorkspace.value = {
...customChannelsByWorkspace.value,
[currentWorkspaceId]: [
...next,
{
id: slugify(`${normalizedNetwork}-${normalizedName}`),
name: normalizedName,
network: normalizedNetwork,
},
],
};
}
function parseTargets(value) {
return (value ?? '')
.split(/[,\n]+/)
.map(target => target.trim())
.filter(Boolean);
}
function slugify(value) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
return {
availableNetworks,
channels,
createChannel,
};
});

View File

@@ -1,182 +0,0 @@
import { computed, ref, watch } from 'vue';
import { defineStore } from 'pinia';
import { useAuthStore } from '@/stores/authStore.js';
import { useWorkspaceStore } from '@/stores/workspaceStore.js';
import { useClient } from '@/plugins/api.js';
export const useClientsStore = defineStore('clients', () => {
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore();
const client = useClient();
const clients = ref([]);
const isLoading = ref(false);
const isCreating = ref(false);
const isUpdating = ref(false);
const isUploadingPortrait = ref(false);
const error = ref(null);
const operationalClient = computed(() => {
if (!clients.value.length) {
return null;
}
return clients.value.find(candidate => candidate.name === workspaceStore.activeWorkspace?.name)
?? clients.value[0];
});
async function fetchClients() {
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
clients.value = [];
error.value = null;
return;
}
isLoading.value = true;
error.value = null;
try {
const response = await client.get('/api/clients', {
params: {
workspaceId: workspaceStore.activeWorkspaceId,
},
});
clients.value = response.data ?? [];
} catch (fetchError) {
console.error('Failed to fetch clients:', fetchError);
clients.value = [];
error.value = 'Failed to load clients.';
} finally {
isLoading.value = false;
}
}
async function createClient(payload) {
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
throw new Error('You must be authenticated to create a client.');
}
if (isCreating.value) {
throw new Error('A client creation request is already in progress.');
}
isCreating.value = true;
error.value = null;
try {
const response = await client.post('/api/clients', {
...payload,
workspaceId: workspaceStore.activeWorkspaceId,
});
if (response.data) {
clients.value = [...clients.value, response.data]
.sort((left, right) => left.name.localeCompare(right.name));
}
return response.data;
} catch (createError) {
console.error('Failed to create client:', createError);
error.value = 'Failed to create client.';
throw createError;
} finally {
isCreating.value = false;
}
}
async function updateClient(clientId, payload) {
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
throw new Error('You must be authenticated to update a client.');
}
if (isUpdating.value) {
throw new Error('A client update request is already in progress.');
}
isUpdating.value = true;
error.value = null;
try {
const response = await client.put(`/api/clients/${clientId}`, payload);
if (response.data) {
clients.value = clients.value
.map(candidate => candidate.id === clientId ? response.data : candidate)
.sort((left, right) => left.name.localeCompare(right.name));
}
return response.data;
} catch (updateError) {
console.error('Failed to update client:', updateError);
error.value = 'Failed to update client.';
throw updateError;
} finally {
isUpdating.value = false;
}
}
async function uploadClientPortrait(clientId, file) {
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
throw new Error('You must be authenticated to upload a client logo.');
}
if (isUploadingPortrait.value) {
throw new Error('A client logo upload is already in progress.');
}
isUploadingPortrait.value = true;
error.value = null;
try {
const formData = new FormData();
formData.append('file', file, file.name || 'client-logo.png');
const response = await client.post(`/api/clients/${clientId}/portrait`, formData);
const blobUrl = response.data?.blobUrl;
if (blobUrl) {
clients.value = clients.value.map(candidate =>
candidate.id === clientId
? { ...candidate, portraitUrl: `${blobUrl}?${Date.now()}` }
: candidate
);
}
return response.data;
} catch (uploadError) {
console.error('Failed to upload client logo:', uploadError);
error.value = 'Failed to upload client logo.';
throw uploadError;
} finally {
isUploadingPortrait.value = false;
}
}
watch(
() => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId],
async ([isAuthenticated, workspaceId]) => {
if (!isAuthenticated || !workspaceId) {
clients.value = [];
error.value = null;
return;
}
await fetchClients();
},
{ immediate: true }
);
return {
clients,
operationalClient,
isLoading,
isCreating,
isUpdating,
isUploadingPortrait,
error,
fetchClients,
createClient,
updateClient,
uploadClientPortrait,
};
});

View File

@@ -1,255 +0,0 @@
import { reactive, ref } from 'vue';
import { defineStore } from 'pinia';
import { useWorkspaceStore } from '@/stores/workspaceStore.js';
import { useClient } from '@/plugins/api.js';
export const useContentItemDetailStore = defineStore('content-item-detail', () => {
const workspaceStore = useWorkspaceStore();
const client = useClient();
const item = ref(null);
const revisions = ref([]);
const assets = ref([]);
const comments = ref([]);
const approvals = ref([]);
const notifications = ref([]);
const isLoading = ref(false);
const error = ref(null);
const actions = reactive({
revision: false,
asset: false,
assetRevision: false,
comment: false,
approval: false,
decision: false,
status: false,
});
function reset() {
item.value = null;
revisions.value = [];
assets.value = [];
comments.value = [];
approvals.value = [];
notifications.value = [];
error.value = null;
}
async function fetchContentItemDetail(contentItemId) {
isLoading.value = true;
error.value = null;
try {
const [
itemResponse,
revisionsResponse,
assetsResponse,
commentsResponse,
approvalsResponse,
notificationsResponse,
] = await Promise.all([
client.get(`/api/content-items/${contentItemId}`),
client.get(`/api/content-items/${contentItemId}/revisions`),
client.get('/api/assets', { params: { contentItemId } }),
client.get('/api/comments', { params: { contentItemId } }),
client.get('/api/approvals', { params: { contentItemId } }),
client.get('/api/notifications', {
params: {
workspaceId: workspaceStore.activeWorkspaceId,
contentItemId,
},
}),
]);
item.value = itemResponse.data;
revisions.value = revisionsResponse.data ?? [];
assets.value = assetsResponse.data ?? [];
comments.value = commentsResponse.data ?? [];
approvals.value = approvalsResponse.data ?? [];
notifications.value = notificationsResponse.data ?? [];
} catch (fetchError) {
console.error('Failed to load content item detail:', fetchError);
reset();
error.value = 'Failed to load the content item detail.';
} finally {
isLoading.value = false;
}
}
async function createRevision(contentItemId, payload) {
actions.revision = true;
try {
const response = await client.post(`/api/content-items/${contentItemId}/revisions`, payload);
if (response.data) {
revisions.value = [response.data, ...revisions.value];
await fetchContentItemDetail(contentItemId);
}
return response.data;
} finally {
actions.revision = false;
}
}
async function addGoogleDriveAsset(contentItemId, payload) {
actions.asset = true;
try {
const response = await client.post('/api/assets/google-drive', {
...payload,
contentItemId,
workspaceId: workspaceStore.activeWorkspaceId,
});
if (response.data) {
assets.value = [...assets.value, response.data];
await fetchNotifications(contentItemId);
}
return response.data;
} finally {
actions.asset = false;
}
}
async function addAssetRevision(contentItemId, assetId, payload) {
actions.assetRevision = true;
try {
const response = await client.post(`/api/assets/${assetId}/revisions`, payload);
if (response.data) {
await fetchAssets(contentItemId);
await fetchNotifications(contentItemId);
}
return response.data;
} finally {
actions.assetRevision = false;
}
}
async function addComment(contentItemId, payload) {
actions.comment = true;
try {
const response = await client.post('/api/comments', {
...payload,
contentItemId,
workspaceId: workspaceStore.activeWorkspaceId,
});
if (response.data) {
comments.value = [...comments.value, response.data];
await fetchNotifications(contentItemId);
}
return response.data;
} finally {
actions.comment = false;
}
}
async function resolveComment(contentItemId, commentId) {
actions.comment = true;
try {
const response = await client.post(`/api/comments/${commentId}/resolve`);
if (response.data) {
comments.value = comments.value.map(comment => comment.id === commentId ? response.data : comment);
await fetchNotifications(contentItemId);
}
return response.data;
} finally {
actions.comment = false;
}
}
async function createApproval(contentItemId, payload) {
actions.approval = true;
try {
const response = await client.post('/api/approvals', {
...payload,
contentItemId,
workspaceId: workspaceStore.activeWorkspaceId,
});
if (response.data) {
approvals.value = [response.data, ...approvals.value];
await fetchContentItem(contentItemId);
await fetchNotifications(contentItemId);
}
return response.data;
} finally {
actions.approval = false;
}
}
async function submitDecision(contentItemId, approvalId, payload) {
actions.decision = true;
try {
const response = await client.post(`/api/approvals/${approvalId}/decisions`, payload);
if (response.data) {
approvals.value = approvals.value.map(approval => approval.id === approvalId ? response.data : approval);
await fetchContentItem(contentItemId);
await fetchNotifications(contentItemId);
}
return response.data;
} finally {
actions.decision = false;
}
}
async function updateStatus(contentItemId, status) {
actions.status = true;
try {
const response = await client.post(`/api/content-items/${contentItemId}/status`, { status });
item.value = response.data;
await fetchNotifications(contentItemId);
return response.data;
} finally {
actions.status = false;
}
}
async function fetchContentItem(contentItemId) {
const response = await client.get(`/api/content-items/${contentItemId}`);
item.value = response.data;
return response.data;
}
async function fetchAssets(contentItemId) {
const response = await client.get('/api/assets', { params: { contentItemId } });
assets.value = response.data ?? [];
return assets.value;
}
async function fetchNotifications(contentItemId) {
const response = await client.get('/api/notifications', {
params: {
workspaceId: workspaceStore.activeWorkspaceId,
contentItemId,
},
});
notifications.value = response.data ?? [];
return notifications.value;
}
return {
item,
revisions,
assets,
comments,
approvals,
notifications,
isLoading,
error,
actions,
reset,
fetchContentItemDetail,
createRevision,
addGoogleDriveAsset,
addAssetRevision,
addComment,
resolveComment,
createApproval,
submitDecision,
updateStatus,
};
});

View File

@@ -1,112 +0,0 @@
import { computed, ref, watch } from 'vue';
import { defineStore } from 'pinia';
import { useAuthStore } from '@/stores/authStore.js';
import { useWorkspaceStore } from '@/stores/workspaceStore.js';
import { useClient } from '@/plugins/api.js';
export const useContentItemsStore = defineStore('content-items', () => {
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore();
const client = useClient();
const items = ref([]);
const isLoading = ref(false);
const isCreating = ref(false);
const error = ref(null);
const activeCount = computed(() =>
items.value.filter(item => item.status !== 'Approved' && item.status !== 'Published' && item.status !== 'Archived')
.length
);
async function fetchContentItems(filters = {}) {
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
items.value = [];
error.value = null;
return;
}
isLoading.value = true;
error.value = null;
try {
const response = await client.get('/api/content-items', {
params: {
workspaceId: workspaceStore.activeWorkspaceId,
clientId: filters.clientId,
projectId: filters.projectId,
},
});
items.value = response.data ?? [];
} catch (fetchError) {
console.error('Failed to fetch content items:', fetchError);
items.value = [];
error.value = 'Failed to load content items.';
} finally {
isLoading.value = false;
}
}
async function createContentItem(payload) {
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
throw new Error('You must be authenticated to create a content item.');
}
if (isCreating.value) {
throw new Error('A content item creation request is already in progress.');
}
isCreating.value = true;
error.value = null;
try {
const response = await client.post('/api/content-items', {
...payload,
workspaceId: workspaceStore.activeWorkspaceId,
});
if (response.data) {
items.value = [response.data, ...items.value];
}
return response.data;
} catch (createError) {
console.error('Failed to create content item:', createError);
error.value = 'Failed to create content item.';
throw createError;
} finally {
isCreating.value = false;
}
}
async function fetchContentItem(id) {
const response = await client.get(`/api/content-items/${id}`);
return response.data;
}
watch(
() => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId],
async ([isAuthenticated, workspaceId]) => {
if (!isAuthenticated || !workspaceId) {
items.value = [];
error.value = null;
return;
}
await fetchContentItems();
},
{ immediate: true }
);
return {
items,
isLoading,
isCreating,
error,
activeCount,
fetchContentItems,
fetchContentItem,
createContentItem,
};
});

View File

@@ -1,89 +0,0 @@
import { computed, ref, watch } from 'vue';
import { defineStore } from 'pinia';
import { useAuthStore } from '@/stores/authStore.js';
import { useWorkspaceStore } from '@/stores/workspaceStore.js';
import { useClient } from '@/plugins/api.js';
export const useNotificationsStore = defineStore('notifications', () => {
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore();
const client = useClient();
const items = ref([]);
const isLoading = ref(false);
const error = ref(null);
const unreadCount = computed(() =>
items.value.filter(item => !item.readAt).length
);
const recentItems = computed(() => items.value.slice(0, 6));
function reset() {
items.value = [];
error.value = null;
}
async function fetchNotifications() {
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
reset();
return;
}
isLoading.value = true;
error.value = null;
try {
const response = await client.get('/api/notifications', {
params: {
workspaceId: workspaceStore.activeWorkspaceId,
},
});
items.value = response.data ?? [];
} catch (fetchError) {
console.error('Failed to fetch notifications:', fetchError);
items.value = [];
error.value = 'Failed to load notifications.';
} finally {
isLoading.value = false;
}
}
async function markAsRead(notificationId) {
try {
await client.post(`/api/notifications/${notificationId}/read`);
items.value = items.value.map(item =>
item.id === notificationId
? { ...item, readAt: item.readAt ?? new Date().toISOString() }
: item
);
} catch (markError) {
console.error('Failed to mark notification as read:', markError);
}
}
watch(
() => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId],
async ([isAuthenticated, workspaceId]) => {
if (!isAuthenticated || !workspaceId) {
reset();
return;
}
await fetchNotifications();
},
{ immediate: true }
);
return {
items,
recentItems,
unreadCount,
isLoading,
error,
reset,
fetchNotifications,
markAsRead,
};
});

View File

@@ -1,99 +0,0 @@
import { ref, watch } from 'vue';
import { defineStore } from 'pinia';
import { useAuthStore } from '@/stores/authStore.js';
import { useWorkspaceStore } from '@/stores/workspaceStore.js';
import { useClient } from '@/plugins/api.js';
export const useProjectsStore = defineStore('projects', () => {
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore();
const client = useClient();
const projects = ref([]);
const isLoading = ref(false);
const isCreating = ref(false);
const error = ref(null);
async function fetchProjects() {
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
projects.value = [];
error.value = null;
return;
}
isLoading.value = true;
error.value = null;
try {
const response = await client.get('/api/projects', {
params: {
workspaceId: workspaceStore.activeWorkspaceId,
},
});
projects.value = response.data ?? [];
} catch (fetchError) {
console.error('Failed to fetch projects:', fetchError);
projects.value = [];
error.value = 'Failed to load projects.';
} finally {
isLoading.value = false;
}
}
async function createProject(payload) {
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
throw new Error('You must be authenticated to create a project.');
}
if (isCreating.value) {
throw new Error('A project creation request is already in progress.');
}
isCreating.value = true;
error.value = null;
try {
const response = await client.post('/api/projects', {
...payload,
workspaceId: workspaceStore.activeWorkspaceId,
});
if (response.data) {
projects.value = [...projects.value, response.data]
.sort((left, right) => left.name.localeCompare(right.name));
}
return response.data;
} catch (createError) {
console.error('Failed to create project:', createError);
error.value = 'Failed to create project.';
throw createError;
} finally {
isCreating.value = false;
}
}
watch(
() => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId],
async ([isAuthenticated, workspaceId]) => {
if (!isAuthenticated || !workspaceId) {
projects.value = [];
error.value = null;
return;
}
await fetchProjects();
},
{ immediate: true }
);
return {
projects,
isLoading,
isCreating,
error,
fetchProjects,
createProject,
};
});

View File

@@ -1,49 +0,0 @@
import { computed } from 'vue';
import { defineStore } from 'pinia';
import { useContentItemsStore } from '@/stores/contentItemsStore.js';
import { useProjectsStore } from '@/stores/projectsStore.js';
const stageByStatus = {
Draft: 'Draft',
'In internal review': 'Internal review',
'Changes requested internally': 'Internal changes requested',
'Internal changes in progress': 'Internal revision',
'Ready for client review': 'Ready for client review',
'In client review': 'Client review',
'Changes requested by client': 'Client changes requested',
'Client changes in progress': 'Client revision',
Approved: 'Approved',
Rejected: 'Rejected',
'Ready to publish': 'Ready to publish',
Published: 'Published',
Archived: 'Archived',
};
export const useReviewQueueStore = defineStore('review-queue', () => {
const contentItemsStore = useContentItemsStore();
const projectsStore = useProjectsStore();
const items = computed(() =>
contentItemsStore.items
.filter(item => item.status !== 'Draft' && item.status !== 'Published' && item.status !== 'Archived')
.map(item => {
const project = projectsStore.projects.find(candidate => candidate.id === item.projectId);
return {
id: item.id,
title: item.title,
projectName: project?.name ?? 'Unknown campaign',
stage: stageByStatus[item.status] ?? item.status,
status: item.status,
dueLabel: item.dueDate ? `Due ${new Date(item.dueDate).toLocaleDateString()}` : 'No due date',
};
})
);
const urgentItems = computed(() => items.value.slice(0, 5));
return {
items,
urgentItems,
};
});

View File

@@ -1,192 +0,0 @@
import {computed, watch} from 'vue'
import {defineStore} from 'pinia'
import {useAuthStore} from "@/stores/authStore.js";
import {useClient} from "@/plugins/api.js";
import {useSessionStorage} from "@vueuse/core";
export const useUserProfileStore = defineStore(
'user-profile',
() => {
const authStore = useAuthStore()
const authWatcher = watch(
() => authStore.isAuthenticated,
async (newValue) => {
if (newValue) {
await fetchCurrentUserProfile()
} else if (!authStore.isRefreshing) {
value.value = undefined
}
})
const value = useSessionStorage(
'user-profile',
{},
{writeDefaults: false})
const fullname = computed(() => {
if (value.value) {
const {firstname, lastname} = value.value;
if (firstname && lastname) {
return `${lastname}, ${firstname}`;
} else if (firstname) {
return firstname;
} else if (lastname) {
return lastname;
}
}
return 'n/a';
})
const alias = computed(() => {
if (value.value) {
return value.value.alias || `${value.value.firstname || ''} ${value.value.lastname || ''}`.trim() || 'Anonyme'
}
return 'Anonyme';
})
const portraitUrl = computed(() => {
return value.value && value.value.portraitUrl
? value.value.portraitUrl
: null
})
const roles = computed(() => value.value?.userRoles ?? [])
const persona = computed(() => value.value?.persona ?? null)
const authorizedWorkspaceIds = computed(() => value.value?.authorizedWorkspaceIds ?? [])
const authorizedClientIds = computed(() => value.value?.authorizedClientIds ?? [])
const authorizedProjectIds = computed(() => value.value?.authorizedProjectIds ?? [])
async function fetchCurrentUserProfile() {
try {
const client = useClient()
const userResponse = await client.get("/api/users/profile");
value.value = userResponse.data
} catch (error) {
console.error(error)
}
}
async function changeFullname(firstname, lastname) {
try {
const client = useClient()
await client.post(
`/api/users/fullname`,
{
firstname: firstname,
lastname: lastname
})
value.value.firstname = firstname;
value.value.lastname = lastname;
} catch (error) {
console.error(error)
}
}
async function changeAlias(alias) {
try {
const client = useClient()
await client.post(
`/api/users/alias`,
{
alias: alias
})
value.value.alias = alias;
} catch (error) {
console.error(error)
}
}
async function changeBirthday(birthdate) {
try {
const client = useClient()
await client.post(
`/api/users/birthdate`,
{
birthdate: birthdate
})
value.value.birthDate = birthdate;
} catch (error) {
console.error(error)
}
}
async function changePhone(phoneNumber) {
try {
const client = useClient()
await client.post(
`/api/users/phone`,
{
phoneNumber: phoneNumber
})
value.value.phoneNumber = phoneNumber;
} catch (error) {
console.error(error)
}
}
async function changeEmail(email) {
try {
const client = useClient()
await client.post(
`/api/users/email`,
{
email: email
})
value.value.email = email;
} catch (error) {
console.error(error)
}
}
async function changeAddress(address) {
try {
const client = useClient()
await client.post(
`/api/users/address`,
{
address: address
})
value.value.address = address;
} catch (error) {
console.error(error)
}
}
async function changePortrait(selectedFile) {
try {
const client = useClient()
const formData = new FormData();
formData.append('file', selectedFile, selectedFile.name || 'portrait.png')
const response = await client.post(
`/api/users/portrait`,
formData)
value.value.portraitUrl = `${response.data.blobUrl}?${Date.now()}` // the Date.now() is for cache-busting
} catch (error) {
console.error(error)
}
}
return {
user: value,
alias,
fullname,
portraitUrl,
roles,
persona,
authorizedWorkspaceIds,
authorizedClientIds,
authorizedProjectIds,
changeFullname,
changeAlias,
changeBirthday,
changePhone,
changeEmail,
changeAddress,
changePortrait
}
})

View File

@@ -1,208 +0,0 @@
import { computed, ref, watch } from 'vue';
import { defineStore } from 'pinia';
import { useAuthStore } from '@/stores/authStore.js';
import { useClient } from '@/plugins/api.js';
export const useWorkspaceStore = defineStore('workspace', () => {
const authStore = useAuthStore();
const client = useClient();
const workspaces = ref([]);
const activeWorkspaceId = ref(null);
const isLoading = ref(false);
const isCreating = ref(false);
const invitesByWorkspace = ref({});
const membersByWorkspace = ref({});
const isInvitesLoading = ref(false);
const isMembersLoading = ref(false);
const isInviting = ref(false);
const error = ref(null);
const activeWorkspace = computed(() =>
workspaces.value.find(workspace => workspace.id === activeWorkspaceId.value) ?? null
);
async function fetchWorkspaces() {
if (!authStore.isAuthenticated) {
workspaces.value = [];
activeWorkspaceId.value = null;
error.value = null;
return;
}
isLoading.value = true;
error.value = null;
try {
const response = await client.get('/api/workspaces');
workspaces.value = response.data ?? [];
if (!workspaces.value.some(workspace => workspace.id === activeWorkspaceId.value)) {
activeWorkspaceId.value = workspaces.value[0]?.id ?? null;
}
} catch (fetchError) {
console.error('Failed to fetch workspaces:', fetchError);
workspaces.value = [];
activeWorkspaceId.value = null;
error.value = 'Failed to load workspaces.';
} finally {
isLoading.value = false;
}
}
async function createWorkspace(payload) {
if (!authStore.isAuthenticated) {
throw new Error('You must be authenticated to create a workspace.');
}
if (isCreating.value) {
throw new Error('A workspace creation request is already in progress.');
}
isCreating.value = true;
error.value = null;
try {
const response = await client.post('/api/workspaces', payload);
if (response.data) {
workspaces.value = [...workspaces.value, response.data]
.sort((left, right) => left.name.localeCompare(right.name));
activeWorkspaceId.value = response.data.id;
try {
await client.post('/api/clients', {
workspaceId: response.data.id,
name: response.data.name,
});
} catch (hiddenClientError) {
console.error('Failed to provision operational client for workspace:', hiddenClientError);
}
}
return response.data;
} catch (createError) {
console.error('Failed to create workspace:', createError);
error.value = 'Failed to create workspace.';
throw createError;
} finally {
isCreating.value = false;
}
}
function setActiveWorkspace(workspaceId) {
if (workspaces.value.some(workspace => workspace.id === workspaceId)) {
activeWorkspaceId.value = workspaceId;
}
}
async function fetchInvites(workspaceId = activeWorkspaceId.value) {
if (!authStore.isAuthenticated || !workspaceId) {
invitesByWorkspace.value = {};
return [];
}
isInvitesLoading.value = true;
try {
const response = await client.get(`/api/workspaces/${workspaceId}/invites`);
invitesByWorkspace.value = {
...invitesByWorkspace.value,
[workspaceId]: response.data ?? [],
};
return invitesByWorkspace.value[workspaceId];
} catch (fetchError) {
console.error('Failed to fetch workspace invites:', fetchError);
throw fetchError;
} finally {
isInvitesLoading.value = false;
}
}
async function fetchMembers(workspaceId = activeWorkspaceId.value) {
if (!authStore.isAuthenticated || !workspaceId) {
membersByWorkspace.value = {};
return [];
}
isMembersLoading.value = true;
try {
const response = await client.get(`/api/workspaces/${workspaceId}/members`);
membersByWorkspace.value = {
...membersByWorkspace.value,
[workspaceId]: response.data ?? [],
};
return membersByWorkspace.value[workspaceId];
} catch (fetchError) {
console.error('Failed to fetch workspace members:', fetchError);
throw fetchError;
} finally {
isMembersLoading.value = false;
}
}
async function inviteMember(payload) {
if (!authStore.isAuthenticated || !activeWorkspaceId.value) {
throw new Error('You must be authenticated to invite a workspace member.');
}
if (isInviting.value) {
throw new Error('A workspace invite request is already in progress.');
}
isInviting.value = true;
try {
const response = await client.post(`/api/workspaces/${activeWorkspaceId.value}/invites`, payload);
invitesByWorkspace.value = {
...invitesByWorkspace.value,
[activeWorkspaceId.value]: [response.data, ...(invitesByWorkspace.value[activeWorkspaceId.value] ?? [])],
};
return response.data;
} catch (inviteError) {
console.error('Failed to create workspace invite:', inviteError);
throw inviteError;
} finally {
isInviting.value = false;
}
}
watch(
() => authStore.isAuthenticated,
async isAuthenticated => {
if (!isAuthenticated) {
workspaces.value = [];
activeWorkspaceId.value = null;
error.value = null;
return;
}
await fetchWorkspaces();
},
{ immediate: true }
);
return {
workspaces,
activeWorkspaceId,
activeWorkspace,
isLoading,
isCreating,
invitesByWorkspace,
membersByWorkspace,
isInvitesLoading,
isMembersLoading,
isInviting,
error,
fetchWorkspaces,
createWorkspace,
fetchInvites,
fetchMembers,
inviteMember,
setActiveWorkspace,
};
});