@rezamirzapour/pod-sdk 1.0.4 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,713 +1,742 @@
1
- # @rezamirzapour/pod-sdk
2
-
3
- > Unified, type-safe, enterprise-grade SDK for **POD Platform** microservices (CMS Content, CMS Product, SSO, CustomPost, Podspace, Podform, Notification, Social, IUMS), engineered specifically for **Next.js App Router**, **Server Components**, and **Server Actions**.
4
-
5
- [![npm version](https://img.shields.io/npm/v/@rezamirzapour/pod-sdk.svg)](https://www.npmjs.com/package/@rezamirzapour/pod-sdk)
6
- [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
7
- [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue?logo=typescript)](https://www.typescriptlang.org/)
8
-
9
- ---
10
-
11
- ## Features
12
-
13
- - 🌐 **Unified Multi-Service Architecture**: Single entry-point managing official POD microservices with official endpoints and configurations.
14
- - ⚡ **Powered by `@rezamirzapour/http`**: Automatic exponential backoff retries, Next.js incremental static regeneration (ISR) caching (`next.revalidate`, tags), and isomorphic execution.
15
- - 🧬 **First-Class TypeScript Generics (`<T, P>`)**: CMS Content, CMS Product, and CustomPost endpoints allow you to define and receive strongly typed metadata structures according to your business schemas.
16
- - 🎨 **Automatic CMS Data Formatter**: Automatically translates nested `metadata.content` and `metadata.product` arrays into flat, strongly typed objects (`formatted[fieldCode]`) and resolves image hashes to Podspace CDN URLs.
17
- - 🛍️ **Full CMS Product Module (RAD API Swagger)**: Complete support for published products, price/discount filters, barcodes, batch publish/unpublish, archiving, and AI timeline search.
18
- - 📦 **Generic CRUD Repository for CustomPost**: Instantiate typed data repositories on POD CustomPost in 2 lines with `create`, `getById`, `getAll`, `createAndBindEntityId`, and `archive`.
19
- - 🔐 **Zero-Dependency Web Crypto RSA Signing**: Built-in RSA-SHA256 signature calculation for POD SSO OTP handshakes using standard Web Crypto API (Node.js, Edge Runtime, Browsers).
20
- - 🚀 **Next.js App Router Ready**: Seamless support for Server Components, Server Actions, Route Handlers, and Client Components.
21
- - 🌳 **Treeshakeable & Dual ESM/CJS**: Ships clean ES modules (`.mjs`) and CommonJS (`.js`) with 100% complete TypeScript declarations (`.d.ts`).
22
-
23
- ---
24
-
25
- ## Microservices Included
26
-
27
- | Service | Accessor | Swagger Tag | Description |
28
- | :--- | :--- | :--- | :--- |
29
- | **CMS Content** | `sdk.cms` | `content` | Fetch, publish, edit, draft, archive, and categorize CMS articles with generic metadata `<T, P>`. |
30
- | **CMS Product** | `sdk.product` / `sdk.cms.products` | `product` | Product catalog, price & discount range filters, barcode lookup, batch publish, and AI search. |
31
- | **CMS Tags & Tree** | `sdk.tags` / `sdk.cms.tags` | `tags` | Manage tag categories and hierarchical tag trees (nodes, parent updates, ancestors, codes). |
32
- | **CustomPost** | `sdk.customPost` | - | Search timeline by metadata `<T>`, custom post CRUD, and high-level typed repository. |
33
- | **SSO** | `sdk.sso` | - | OAuth2 handshake, OTP dispatch with digital RSA signature, OTP verify, token generation, and user profile. |
34
- | **Podspace** | `sdk.podspace` | - | File upload (FormData) and public/private download URL resolution. |
35
- | **Podform** | `sdk.podform` | - | Survey & form response submission, question/form structure retrieval. |
36
- | **Notification** | `sdk.notification` | - | SMS delivery and bulk messaging with tracking. |
37
- | **Social** | `sdk.social` | - | User comments, reactions (likes/dislikes), rates, and social post interactions. |
38
- | **IUMS** | `sdk.iums` | - | Student and user identity inspection by national code or student ID. |
39
-
40
- ---
41
-
42
- ## Installation
43
-
44
- ```bash
45
- npm install @rezamirzapour/pod-sdk @rezamirzapour/http @rezamirzapour/logger
46
- ```
47
-
48
- ---
49
-
50
- ## Quickstart
51
-
52
- ### 1. Initialize the Client
53
-
54
- ```typescript
55
- // lib/pod.ts
56
- import { createPodSdk } from '@rezamirzapour/pod-sdk';
57
-
58
- export const podSdk = createPodSdk({
59
- apiToken: process.env.POD_API_TOKEN,
60
- clientId: process.env.POD_CLIENT_ID,
61
- clientSecret: process.env.POD_CLIENT_SECRET,
62
- privateKeyPem: process.env.POD_PRIVATE_KEY_PEM, // RSA Private Key for SSO OTP signing
63
- revalidate: 3600, // Default ISR revalidation in seconds
64
- urls: {
65
- // Optional overrides (defaults to official POD endpoints)
66
- accounts: 'https://accounts.pod.ir',
67
- apiPod: 'https://api.pod.ir',
68
- cms: 'https://cms.pod.ir',
69
- podspace: 'https://podspace.pod.ir',
70
- podform: 'https://podform.pod.ir',
71
- notification: 'https://notification.pod.ir',
72
- iums: 'https://iums.pod.ir',
73
- },
74
- });
75
- ```
76
-
77
- ---
78
-
79
- ## Deep Dive 1: CMS Content & Data Formatting
80
-
81
- POD CMS stores dynamic fields inside a `metadata.content` array of `{ code, value, type }` objects. `@rezamirzapour/pod-sdk` automatically formats these into clean typed key-value pairs (`item.formatted`) and allows an optional custom `normalizer` function (`item.__normalized`).
82
-
83
- > [!NOTE]
84
- > `getContent2` has been removed. For authenticated/management content retrieval (using `Access-Token`), call `podSdk.cms.getAllContents()` or pass headers to `getContent()`.
85
-
86
- ### 🏗️ Clean Architecture Pattern: Dedicated Data Access Layer (`NewsApi`)
87
-
88
- In enterprise Next.js applications, avoid calling SDK clients directly inside UI components. Instead, encapsulate CMS queries, generics, normalizers, and caching logic inside a dedicated Data Access Layer (`services/newsApi.ts`).
89
-
90
- #### Step 1: Create the Data Access Layer (`services/newsApi.ts`)
91
-
92
- ```typescript
93
- // services/newsApi.ts
94
- import { podSdk } from '@/lib/pod';
95
-
96
- // 1. Define the raw CMS Formatted Metadata schema (T)
97
- export interface NewsArticleFormatted {
98
- title: string;
99
- coverImage: string; // Auto-transformed from hash to full Podspace CDN URL!
100
- lead: string;
101
- body: string;
102
- author?: string;
103
- }
104
-
105
- // 2. Define computed / normalized domain attributes (P)
106
- export interface NewsArticleNormalized {
107
- summary: string;
108
- readTime: number;
109
- }
110
-
111
- // 3. Define the Clean Domain Model consumed by your React Components
112
- export interface NewsItem {
113
- id: number;
114
- entityId: number;
115
- title: string;
116
- coverImage: string;
117
- summary: string;
118
- body: string;
119
- readTime: number;
120
- publishedAt?: number;
121
- }
122
-
123
- export interface GetNewsListParams {
124
- page?: number;
125
- pageSize?: number;
126
- tagTrees?: string[];
127
- }
128
-
129
- export class NewsApi {
130
- private static readonly CONTENT_TYPE = 'company_news';
131
-
132
- /**
133
- * Fetches published news articles with Next.js App Router ISR Caching
134
- */
135
- public static async getNewsList({
136
- page = 1,
137
- pageSize = 10,
138
- tagTrees,
139
- }: GetNewsListParams = {}): Promise<{ items: NewsItem[]; total: number }> {
140
- const offset = (page - 1) * pageSize;
141
-
142
- // Call getContent with exact generics: <NewsArticleFormatted, NewsArticleNormalized>
143
- const response = await podSdk.cms.getContent<NewsArticleFormatted, NewsArticleNormalized>(
144
- {
145
- contentTypeUniqueId: this.CONTENT_TYPE,
146
- size: pageSize,
147
- offset,
148
- tagTrees,
149
- },
150
- {
151
- // Custom normalizer executed during data formatting
152
- normalizer: (item) => ({
153
- summary: item.formatted.lead || item.formatted.title,
154
- readTime: Math.max(1, Math.ceil((item.formatted.body?.length || 0) / 500)),
155
- }),
156
- revalidate: 1800, // Next.js ISR: cache for 30 minutes
157
- }
158
- );
159
-
160
- if (response.hasError || !Array.isArray(response.result)) {
161
- return { items: [], total: 0 };
162
- }
163
-
164
- const items: NewsItem[] = response.result.map((item) => ({
165
- id: item.id,
166
- entityId: item.entityId,
167
- title: item.formatted.title,
168
- coverImage: item.formatted.coverImage,
169
- summary: item.__normalized.summary,
170
- body: item.formatted.body,
171
- readTime: item.__normalized.readTime,
172
- publishedAt: item.timestamp,
173
- }));
174
-
175
- return {
176
- items,
177
- total: response.count || items.length,
178
- };
179
- }
180
-
181
- /**
182
- * Fetches a single news article by its entity ID
183
- */
184
- public static async getNewsById(entityId: number): Promise<NewsItem | null> {
185
- const response = await podSdk.cms.getContentByEntityId<NewsArticleFormatted, NewsArticleNormalized>({
186
- entityId,
187
- contentTypeUniqueId: this.CONTENT_TYPE,
188
- });
189
-
190
- const item = response.result?.[0];
191
- if (!item) return null;
192
-
193
- return {
194
- id: item.id,
195
- entityId: item.entityId,
196
- title: item.formatted.title,
197
- coverImage: item.formatted.coverImage,
198
- summary: item.formatted.lead || item.formatted.title,
199
- body: item.formatted.body,
200
- readTime: Math.max(1, Math.ceil((item.formatted.body?.length || 0) / 500)),
201
- publishedAt: item.timestamp,
202
- };
203
- }
204
-
205
- /**
206
- * Publishes a new article to CMS
207
- */
208
- public static async publishNews(data: {
209
- title: string;
210
- lead: string;
211
- body: string;
212
- imageHash: string;
213
- }) {
214
- return podSdk.cms.addContent({
215
- contentTypeUniqueId: this.CONTENT_TYPE,
216
- body: {
217
- name: data.title,
218
- fieldCode: ['title', 'lead', 'body', 'coverImage'],
219
- fieldValue: [data.title, data.lead, data.body, data.imageHash],
220
- canComment: true,
221
- canLike: true,
222
- enable: true,
223
- isSearchable: true,
224
- },
225
- });
226
- }
227
- }
228
- ```
229
-
230
- #### Step 2: Consume `NewsApi` in Next.js Server Components (`app/news/page.tsx`)
231
-
232
- ```tsx
233
- // app/news/page.tsx (Server Component)
234
- import { NewsApi } from '@/services/newsApi';
235
- import Link from 'next/link';
236
-
237
- interface NewsPageProps {
238
- searchParams: { page?: string };
239
- }
240
-
241
- export default async function NewsPage({ searchParams }: NewsPageProps) {
242
- const page = Number(searchParams.page) || 1;
243
- const { items, total } = await NewsApi.getNewsList({ page, pageSize: 12 });
244
-
245
- return (
246
- <main className="container mx-auto py-8 px-4">
247
- <h1 className="text-3xl font-bold mb-6">Company News</h1>
248
-
249
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
250
- {items.map((news) => (
251
- <article key={news.entityId} className="border rounded-xl overflow-hidden shadow-sm hover:shadow-md transition">
252
- {news.coverImage && (
253
- <img
254
- src={news.coverImage}
255
- alt={news.title}
256
- className="w-full h-48 object-cover"
257
- />
258
- )}
259
- <div className="p-4">
260
- <h2 className="text-xl font-semibold mb-2">
261
- <Link href={`/news/${news.entityId}`}>{news.title}</Link>
262
- </h2>
263
- <p className="text-gray-600 text-sm line-clamp-2">{news.summary}</p>
264
- <div className="mt-4 flex items-center justify-between text-xs text-gray-400">
265
- <span>{news.readTime} min read</span>
266
- <Link href={`/news/${news.entityId}`} className="text-blue-600 font-medium hover:underline">
267
- Read more
268
- </Link>
269
- </div>
270
- </div>
271
- </article>
272
- ))}
273
- </div>
274
- </main>
275
- );
276
- }
277
- ```
278
-
279
- ---
280
-
281
- ## Deep Dive 2: CMS Product Module (RAD API Swagger)
282
-
283
- The CMS Product module connects directly to POD RAD API endpoints ([Swagger documentation](https://rad-sandbox.sandpod.ir/api/documentation?tag=product)). It handles products with prices, discounts, barcodes, dynamic `metadata.product` field formatting, and batch management.
284
-
285
- ### Accessing Products
286
-
287
- You can access product methods either via `podSdk.product` or `podSdk.cms.products`:
288
-
289
- ```typescript
290
- // Both point to the same CmsProductService instance
291
- podSdk.product.getProducts(...);
292
- podSdk.cms.products.getProducts(...);
293
- ```
294
-
295
- ### 🛍️ Clean Architecture Pattern: Product Data Access Layer (`ProductsApi`)
296
-
297
- ```typescript
298
- // services/productsApi.ts
299
- import { podSdk } from '@/lib/pod';
300
-
301
- // 1. Raw formatted product metadata (T)
302
- export interface LaptopFormatted {
303
- cpu: string;
304
- ram: string;
305
- storage: string;
306
- displayPhoto: string; // Auto-transformed from hash to Podspace CDN URL
307
- }
308
-
309
- // 2. Computed / normalized presentation fields (P)
310
- export interface LaptopNormalized {
311
- finalPrice: number;
312
- discountBadge: string;
313
- }
314
-
315
- // 3. Clean Domain Product Model
316
- export interface ProductItemModel {
317
- entityId: number;
318
- name: string;
319
- price: number;
320
- finalPrice: number;
321
- discountBadge: string;
322
- photoUrl: string;
323
- specs: {
324
- cpu: string;
325
- ram: string;
326
- storage: string;
327
- };
328
- }
329
-
330
- export class ProductsApi {
331
- private static readonly PRODUCT_TYPE = 'laptops';
332
-
333
- /**
334
- * Fetches published products with price & discount filters
335
- */
336
- public static async getProducts(params: {
337
- fromPrice?: number;
338
- toPrice?: number;
339
- barcode?: string;
340
- page?: number;
341
- pageSize?: number;
342
- } = {}): Promise<ProductItemModel[]> {
343
- const { fromPrice, toPrice, barcode, page = 1, pageSize = 20 } = params;
344
- const offset = (page - 1) * pageSize;
345
-
346
- const response = await podSdk.product.getProducts<LaptopFormatted, LaptopNormalized>(
347
- {
348
- productTypeUniqueId: this.PRODUCT_TYPE,
349
- fromPrice,
350
- toPrice,
351
- barcode,
352
- size: pageSize,
353
- offset,
354
- },
355
- {
356
- normalizer: (item) => {
357
- const discount = item.discount || 0;
358
- const finalPrice = item.price ? item.price * (1 - discount / 100) : 0;
359
- return {
360
- finalPrice,
361
- discountBadge: discount > 0 ? `${discount}% OFF` : '',
362
- };
363
- },
364
- revalidate: 3600, // Next.js ISR: cache for 1 hour
365
- }
366
- );
367
-
368
- if (response.hasError || !Array.isArray(response.result)) {
369
- return [];
370
- }
371
-
372
- return response.result.map((item) => ({
373
- entityId: item.entityId,
374
- name: item.name || '',
375
- price: item.price || 0,
376
- finalPrice: item.__normalized.finalPrice,
377
- discountBadge: item.__normalized.discountBadge,
378
- photoUrl: item.formatted.displayPhoto,
379
- specs: {
380
- cpu: item.formatted.cpu,
381
- ram: item.formatted.ram,
382
- storage: item.formatted.storage,
383
- },
384
- }));
385
- }
386
-
387
- /**
388
- * Retrieves single product by entityId
389
- */
390
- public static async getProductById(entityId: number) {
391
- const res = await podSdk.product.getProductByEntityId<LaptopFormatted, LaptopNormalized>({
392
- entityId,
393
- productTypeUniqueId: this.PRODUCT_TYPE,
394
- });
395
- return res.result?.[0] || null;
396
- }
397
-
398
- /**
399
- * Look up product by barcode scanner
400
- */
401
- public static async getProductByBarcode(barcode: string) {
402
- return podSdk.product.getProductByBarcode<LaptopFormatted>(barcode);
403
- }
404
-
405
- /**
406
- * Adds and publishes a new product
407
- */
408
- public static async createProduct(data: {
409
- name: string;
410
- price: number;
411
- cpu: string;
412
- ram: string;
413
- storage: string;
414
- photoHash: string;
415
- }) {
416
- return podSdk.product.addProduct({
417
- productTypeUniqueId: this.PRODUCT_TYPE,
418
- body: {
419
- name: data.name,
420
- enable: true,
421
- fieldCode: ['cpu', 'ram', 'storage', 'displayPhoto', 'price'],
422
- fieldValue: [data.cpu, data.ram, data.storage, data.photoHash, data.price],
423
- },
424
- });
425
- }
426
- }
427
- ```
428
-
429
- ### CMS Methods Reference
430
-
431
- #### Content Methods (`podSdk.cms`)
432
-
433
- | Method | HTTP | Path | Description |
434
- | :--- | :--- | :--- | :--- |
435
- | `getContent<T, P>(params, opts)` | `GET` | `/api/core/contents/enable` | Get published enabled contents with generic typing. |
436
- | `getAllContents<T, P>(params, opts)` | `GET` | `/api/core/contents` | Get contents with managing credentials (`Access-Token`). |
437
- | `getMyContents<T, P>(params, opts)` | `GET` | `/api/core/contents/my` | Get current user/client contents. |
438
- | `getContentsByType<T, P>(typeId, params)` | `GET` | `/api/core/contents/{contentTypeUniqueId}` | Get contents by ContentType. |
439
- | `getContentByEntityId<T, P>(params)` | `GET` | `/api/core/contents/enable/{entityId}` | Get single published content item. |
440
- | `getContentByUniqueId<T, P>(uniqueId)` | `GET` | `/api/core/contents/{uniqueId}/byContentUniqueId/enable` | Get content by uniqueId. |
441
- | `searchContent<T, P>(params)` | `GET` | `/api/core/contents/search` | Search published contents by keyword. |
442
- | `addContent<T, P>(params)` | `POST` | `/api/core/contents/{contentTypeUniqueId}/add-publish` | Add and publish content item. |
443
- | `createContent<T, P>(params)` | `POST` | `/api/core/contents/{contentTypeUniqueId}` | Create unpublished/draft content. |
444
- | `editContent<T, P>(entityId, params)` | `POST` | `/api/core/contents/{typeId}/{entityId}/edit-publish` | Update and publish content item. |
445
- | `updateContent<T, P>(entityId, params)` | `POST` | `/api/core/contents/{typeId}/{entityId}` | Update content item. |
446
- | `patchContent<T, P>(entityId, params)` | `PATCH` | `/api/core/contents/{typeId}/{entityId}` | Partial update content. |
447
- | `publishContent(typeId, entityId)` | `POST` | `/api/core/contents/{typeId}/publish/{entityId}` | Publish single content item. |
448
- | `unpublishContent(typeId, entityId)` | `POST` | `/api/core/contents/{typeId}/unpublish/{entityId}` | Unpublish single content item. |
449
- | `batchPublish(params)` | `PUT` | `/api/core/contents/{typeId}/publish` | Batch publish multiple contents. |
450
- | `batchUnpublish(params)` | `PUT` | `/api/core/contents/{typeId}/unpublish` | Batch unpublish multiple contents. |
451
- | `archiveContent(params)` | `POST` | `/api/core/contents/{typeId}/archive` | Archive content items. |
452
- | `unarchiveContent(params)` | `POST` | `/api/core/contents/{typeId}/unarchive` | Unarchive content items. |
453
- | `getArchivedContents(params)` | `GET` | `/api/core/contents/archive` | Get archived contents list. |
454
- | `getDrafts(params)` | `GET` | `/api/core/contents/draft` | Get drafts list. |
455
- | `createDraft(params)` | `POST` | `/api/core/contents/{typeId}/draft` | Create a draft. |
456
- | `getComments(typeId, entityId)` | `GET` | `/api/core/contents/{typeId}/comments/{entityId}` | Get comments on content. |
457
- | `getLikes(uniqueId, entityId)` | `GET` | `/api/core/contents/{uniqueId}/like/{entityId}` | Get likes of content item. |
458
- | `getCategories(params)` | `GET` | `/api/core/tags/root/tree/enable` | Get category/tag tree. |
459
- | `timelineSearch(query)` | `GET` | `/api/core/contents/ai/timeline-search/enable` | Advanced AI timeline search. |
460
-
461
- #### Product Methods (`podSdk.product` / `podSdk.cms.products`)
462
-
463
- | Method | HTTP | Path | Description |
464
- | :--- | :--- | :--- | :--- |
465
- | `getProducts<T, P>(params, opts)` | `GET` | `/api/core/products/enable` | Get enabled published products with price/discount filters. |
466
- | `getAllProducts<T, P>(params, opts)` | `GET` | `/api/core/products` | Get products with provider credentials (`Access-Token`). |
467
- | `getManageProducts<T, P>(params, opts)`| `GET` | `/api/core/products/manage` | Get products for manager view. |
468
- | `getProductsByType<T, P>(typeId, params)`| `GET` | `/api/core/products/{productTypeUniqueId}` | Get products by ProductType. |
469
- | `getProductByEntityId<T, P>(params)` | `GET` | `/api/core/products/enable/{entityId}` | Get single product by entityId. |
470
- | `getProductByUniqueId<T, P>(uniqueId)` | `GET` | `/api/core/products/{uniqueId}/byProductUniqueId/enable` | Get product by uniqueId. |
471
- | `getProductByBarcode<T, P>(barcode)` | `GET` | `/api/core/products/barcode/enable` | Lookup products matching a barcode. |
472
- | `searchProducts<T, P>(params)` | `GET` | `/api/core/products/enable/search` | Search published products. |
473
- | `addProduct<T, P>(params)` | `POST` | `/api/core/products/{productTypeUniqueId}/add-publish` | Create and publish new product. |
474
- | `createProduct<T, P>(params)` | `POST` | `/api/core/products/{productTypeUniqueId}` | Create draft/unpublished product. |
475
- | `editProduct<T, P>(entityId, params)` | `POST` | `/api/core/products/{typeId}/{entityId}/edit-publish` | Edit and publish existing product. |
476
- | `updateProduct<T, P>(entityId, params)` | `POST` | `/api/core/products/{typeId}/{entityId}` | Update existing product. |
477
- | `patchProduct<T, P>(entityId, params)` | `PATCH` | `/api/core/products/{typeId}/{entityId}` | Partial update product. |
478
- | `publishProduct(typeId, entityId)` | `POST` | `/api/core/products/{typeId}/publish/{entityId}` | Publish product. |
479
- | `unpublishProduct(typeId, entityId)` | `POST` | `/api/core/products/{typeId}/unpublish/{entityId}` | Unpublish product. |
480
- | `batchPublish(params)` | `PUT` | `/api/core/products/publish` | Batch publish multiple products. |
481
- | `batchUnpublish(params)` | `PUT` | `/api/core/products/unpublish` | Batch unpublish multiple products. |
482
- | `archiveProduct(params)` | `POST` | `/api/core/products/archive` | Archive products. |
483
- | `unarchiveProduct(params)` | `POST` | `/api/core/products/unarchive` | Unarchive products. |
484
- | `getArchivedProducts(params)` | `GET` | `/api/core/products/archive` | Get archived products list. |
485
- | `timelineSearch(query)` | `GET` | `/api/core/products/ai/timeline-search/enable` | AI timeline search on products. |
486
-
487
- ---
488
-
489
- ## Deep Dive 3: CMS Tags & Tag Categories (RAD API Swagger: tag=tags)
490
-
491
- Manage tag categories and multi-level hierarchical tag trees ([Swagger documentation](https://rad-sandbox.sandpod.ir/api/documentation?tag=tags)).
492
-
493
- ### Accessing Tag Services
494
-
495
- Access via `podSdk.tags` or `podSdk.cms.tags`:
496
-
497
- ```typescript
498
- // Category Management (with Access-Token)
499
- const categories = await podSdk.tags.getTagCategories({ size: 20 });
500
- await podSdk.tags.createTagCategory({ name: 'Technology', desc: 'Tech articles' });
501
- await podSdk.tags.publishTagCategory(categoryId);
502
-
503
- // Public Hierarchical Tag Tree (without token, defaults to 'root')
504
- const rootTree = await podSdk.tags.getTagTree('root', { levelCount: 3 });
505
-
506
- // Backward-compatible shortcut
507
- const categoriesTree = await podSdk.cms.getCategories({ levelCount: 3 });
508
-
509
- // Tree Node Management (create, update parent, code, ancestors)
510
- await podSdk.tags.createTagTreeItem('technology', {
511
- name: 'Next.js',
512
- code: 'NEXTJS',
513
- parentId: 10,
514
- });
515
- await podSdk.tags.updateTagTreeParent('technology', nodeId, newParentId);
516
- await podSdk.tags.updateTagTreeCode('technology', nodeId, 'NEW_CODE');
517
- const ancestors = await podSdk.tags.getTagTreeAncestors('technology', nodeId);
518
- ```
519
-
520
- ### Tag Methods Reference (`podSdk.tags` / `podSdk.cms.tags`)
521
-
522
- | Method | HTTP | Path | Description |
523
- | :--- | :--- | :--- | :--- |
524
- | `getTagCategories(params)` | `GET` | `/api/core/tags/category` | List tag categories (manage). |
525
- | `createTagCategory(params)` | `POST` | `/api/core/tags/category` | Create new tag category. |
526
- | `getTagCategory(id)` | `GET` | `/api/core/tags/category/{id}` | Show single tag category. |
527
- | `updateTagCategory(id, params)` | `PUT` | `/api/core/tags/category/{id}` | Update tag category. |
528
- | `publishTagCategory(id)` | `PUT` | `/api/core/tags/category/{id}/publish` | Publish tag category. |
529
- | `unpublishTagCategory(id)` | `PUT` | `/api/core/tags/category/{id}/unpublish` | Unpublish tag category. |
530
- | `getTagTree(categoryId, params)` | `GET` | `/api/core/tags/{categoryId}/tree/enable` | Get enabled tag tree (public). |
531
- | `getManageTagTree(categoryId, params)` | `GET` | `/api/core/tags/{categoryId}/tree/manage` | Get tag tree (manage with token). |
532
- | `createTagTreeItem(categoryId, params)` | `POST` | `/api/core/tags/{categoryId}/tree` | Create tag tree node for category. |
533
- | `getTagTreeItem(categoryId, id)` | `GET` | `/api/core/tags/{categoryId}/tree/{id}/enable` | Show enabled tag tree node. |
534
- | `getManageTagTreeItem(categoryId, id)` | `GET` | `/api/core/tags/{categoryId}/tree/{id}/manage` | Show tag tree node (manage). |
535
- | `updateTagTreeItem(categoryId, id, params)` | `PUT` | `/api/core/tags/{categoryId}/tree/{id}` | Update tag tree node. |
536
- | `publishTagTreeItem(categoryId, id)` | `PUT` | `/api/core/tags/{categoryId}/tree/publish/{id}` | Publish tag tree node. |
537
- | `unpublishTagTreeItem(categoryId, id)` | `PUT` | `/api/core/tags/{categoryId}/tree/unpublish/{id}` | Unpublish tag tree node. |
538
- | `updateTagTreeParent(categoryId, id, parentId)` | `PUT` | `/api/core/tags/{categoryId}/tree/parent/{id}` | Update parent of tag tree node. |
539
- | `getTagTreeAncestors(categoryId, id, params)` | `GET` | `/api/core/tags/{categoryId}/tree/parent/{id}/enable` | Get node ancestors (public). |
540
- | `getManageTagTreeAncestors(categoryId, id, params)`| `GET` | `/api/core/tags/{categoryId}/tree/parent/{id}/manage` | Get node ancestors (manage). |
541
- | `updateTagTreeCode(categoryId, id, code)` | `PUT` | `/api/core/tags/{categoryId}/tree/code/{id}` | Update code of tag tree node. |
542
-
543
- ---
544
-
545
- ## Deep Dive 4: CustomPost & Generic Typed Repositories
546
-
547
- `CustomPost` allows persisting arbitrary JSON schemas on the POD Platform. `@rezamirzapour/pod-sdk` provides two complementary approaches:
548
-
549
- 1. **Direct Service Method Calls with Generics (`searchTimelineByMetadata<T>`, `getCustomPost<T>`, `addCustomPost<T>`)**
550
- 2. **High-Level Typed CRUD Repository (`CustomPostCrudService<DataType>`)**
551
-
552
- ```typescript
553
- // lib/blog.ts
554
- import { podSdk } from '@/lib/pod';
555
-
556
- export interface BlogPostMeta {
557
- slug: string;
558
- title: string;
559
- summary: string;
560
- views: number;
561
- }
562
-
563
- // Search posts with typed metadata query
564
- export async function getPublishedPosts() {
565
- const response = await podSdk.customPost.searchTimelineByMetadata<BlogPostMeta>({
566
- entityName: 'blog_post',
567
- metadata: { views: 100 },
568
- size: 20,
569
- offset: 0,
570
- });
571
-
572
- return response.result.map((r) => r.item.metadata);
573
- }
574
- ```
575
-
576
- ---
577
-
578
- ## Other POD Microservices
579
-
580
- ### 4. POD SSO (OTP Handshake with Web Crypto RSA Signature)
581
-
582
- ```typescript
583
- // app/actions/auth.ts
584
- 'use server';
585
-
586
- import { podSdk } from '@/lib/pod';
587
-
588
- // Step 1: Handshake and send OTP
589
- export async function sendOtp(phoneNumber: string, clientIp: string) {
590
- const handshake = await podSdk.sso.handshake(`web-${Date.now()}`, clientIp);
591
- if (handshake.hasError || !handshake.result?.keyId) {
592
- throw new Error(handshake.message || 'SSO Handshake failed');
593
- }
594
-
595
- // Automatic RSA-SHA256 signature using Web Crypto API
596
- const otpRes = await podSdk.sso.sendOtpCode(handshake.result.keyId, phoneNumber);
597
- return {
598
- success: !otpRes.hasError,
599
- authorization: otpRes.authorization,
600
- };
601
- }
602
-
603
- // Step 2: Verify OTP code & exchange for tokens
604
- export async function verifyOtp(authorization: string, phoneNumber: string, code: string) {
605
- const verify = await podSdk.sso.verifyOtpCode(authorization, phoneNumber, code);
606
- if (verify.hasError || !verify.result?.code) {
607
- throw new Error(verify.message || 'OTP verification failed');
608
- }
609
-
610
- const tokens = await podSdk.sso.generateToken(verify.result.code);
611
- return tokens.result; // { access_token, refresh_token, expires_in }
612
- }
613
-
614
- // Step 3: Fetch profile with access token
615
- export async function getProfile(accessToken: string) {
616
- const profile = await podSdk.sso.getUserProfile(accessToken);
617
- return profile.result;
618
- }
619
- ```
620
-
621
- ---
622
-
623
- ### 5. Podspace (File Uploads & CDN URLs)
624
-
625
- ```typescript
626
- // app/actions/upload.ts
627
- 'use server';
628
-
629
- import { podSdk } from '@/lib/pod';
630
-
631
- export async function uploadFile(formData: FormData) {
632
- const res = await podSdk.podspace.uploadFile(formData, '/uploads', true);
633
- if (res.hasError) {
634
- throw new Error(res.message || 'Upload failed');
635
- }
636
-
637
- const publicUrl = podSdk.podspace.getFileUrl(res.result.hash, true);
638
- return { url: publicUrl, hash: res.result.hash };
639
- }
640
- ```
641
-
642
- ---
643
-
644
- ### 6. Notification (SMS Delivery)
645
-
646
- ```typescript
647
- // app/actions/notify.ts
648
- 'use server';
649
-
650
- import { podSdk } from '@/lib/pod';
651
-
652
- export async function sendSms(phoneNumber: string, text: string) {
653
- return podSdk.notification.sendSms({
654
- receptor: phoneNumber,
655
- message: text,
656
- });
657
- }
658
- ```
659
-
660
- ---
661
-
662
- ### 7. Social (Comments & Likes)
663
-
664
- ```typescript
665
- // app/actions/social.ts
666
- 'use server';
667
-
668
- import { podSdk } from '@/lib/pod';
669
-
670
- export async function addComment(postId: number, content: string) {
671
- return podSdk.social.addComment({
672
- postId,
673
- text: content,
674
- });
675
- }
676
-
677
- export async function likePost(postId: number) {
678
- return podSdk.social.likePost(postId);
679
- }
680
- ```
681
-
682
- ---
683
-
684
- ### 8. IUMS (Identity & University Management Service)
685
-
686
- ```typescript
687
- // app/actions/iums.ts
688
- 'use server';
689
-
690
- import { podSdk } from '@/lib/pod';
691
-
692
- export async function getStudentByNationalCode(nationalCode: string) {
693
- return podSdk.iums.getStudentInformationByNationalCode(nationalCode);
694
- }
695
- ```
696
-
697
- ---
698
-
699
- ## Environment Variables Reference
700
-
701
- ```env
702
- # .env.local
703
- POD_API_TOKEN=your_api_token_here
704
- POD_CLIENT_ID=your_client_id_here
705
- POD_CLIENT_SECRET=your_client_secret_here
706
- POD_PRIVATE_KEY_PEM="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
707
- ```
708
-
709
- ---
710
-
711
- ## License
712
-
713
- MIT © [Reza](https://github.com/rezamirzapour2)
1
+ # @rezamirzapour/pod-sdk
2
+
3
+ > Unified, type-safe, enterprise-grade SDK for **POD Platform** microservices (CMS Content, CMS Product, SSO, CustomPost, Podspace, Podform, Notification, Social, IUMS), engineered specifically for **Next.js App Router**, **Server Components**, and **Server Actions**.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@rezamirzapour/pod-sdk.svg)](https://www.npmjs.com/package/@rezamirzapour/pod-sdk)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
7
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue?logo=typescript)](https://www.typescriptlang.org/)
8
+
9
+ ---
10
+
11
+ ## Features
12
+
13
+ - 🌐 **Unified Multi-Service Architecture**: Single entry-point managing official POD microservices with official endpoints and configurations.
14
+ - ⚡ **Powered by `@rezamirzapour/http`**: Automatic exponential backoff retries, Next.js incremental static regeneration (ISR) caching (`next.revalidate`, tags), and isomorphic execution.
15
+ - 🧬 **First-Class TypeScript Generics (`<T, P>`)**: CMS Content, CMS Product, and CustomPost endpoints allow you to define and receive strongly typed metadata structures according to your business schemas.
16
+ - 🎨 **Automatic CMS Data Formatter**: Automatically translates nested `metadata.content` and `metadata.product` arrays into flat, strongly typed objects (`formatted[fieldCode]`) and resolves image hashes to Podspace CDN URLs.
17
+ - 🛍️ **Full CMS Product Module (RAD API Swagger)**: Complete support for published products, price/discount filters, barcodes, batch publish/unpublish, archiving, and AI timeline search.
18
+ - 📦 **Generic CRUD Repository for CustomPost**: Instantiate typed data repositories on POD CustomPost in 2 lines with `create`, `getById`, `getAll`, `createAndBindEntityId`, and `archive`.
19
+ - 🔐 **Zero-Dependency Web Crypto RSA Signing**: Built-in RSA-SHA256 signature calculation for POD SSO OTP handshakes using standard Web Crypto API (Node.js, Edge Runtime, Browsers).
20
+ - 🚀 **Next.js App Router Ready**: Seamless support for Server Components, Server Actions, Route Handlers, and Client Components.
21
+ - 🌳 **Treeshakeable & Dual ESM/CJS**: Ships clean ES modules (`.mjs`) and CommonJS (`.js`) with 100% complete TypeScript declarations (`.d.ts`).
22
+
23
+ ---
24
+
25
+ ## Microservices Included
26
+
27
+ | Service | Accessor | Swagger Tag | Description |
28
+ | :--- | :--- | :--- | :--- |
29
+ | **CMS Content** | `sdk.cms` | `content` | Fetch, publish, edit, draft, archive, and categorize CMS articles with generic metadata `<T, P>`. |
30
+ | **CMS Product** | `sdk.product` / `sdk.cms.products` | `product` | Product catalog, price & discount range filters, barcode lookup, batch publish, and AI search. |
31
+ | **CMS Tags & Tree** | `sdk.tags` / `sdk.cms.tags` | `tags` | Manage tag categories and hierarchical tag trees (nodes, parent updates, ancestors, codes). |
32
+ | **CustomPost** | `sdk.customPost` | - | Search timeline by metadata `<T>`, custom post CRUD, and high-level typed repository. |
33
+ | **SSO** | `sdk.sso` | - | OAuth2 handshake, OTP dispatch with digital RSA signature, OTP verify, token generation, and user profile. |
34
+ | **Podspace** | `sdk.podspace` | - | File upload (FormData) and public/private download URL resolution. |
35
+ | **Podform** | `sdk.podform` | - | Survey & form response submission, question/form structure retrieval. |
36
+ | **Notification** | `sdk.notification` | - | SMS delivery and bulk messaging with tracking. |
37
+ | **Social** | `sdk.social` | - | User comments, reactions (likes/dislikes), rates, and social post interactions. |
38
+ | **IUMS** | `sdk.iums` | - | Student and user identity inspection by national code or student ID. |
39
+
40
+ ---
41
+
42
+ ## Installation
43
+
44
+ ```bash
45
+ npm install @rezamirzapour/pod-sdk @rezamirzapour/http @rezamirzapour/logger
46
+ ```
47
+
48
+ ---
49
+
50
+ ## Environment Variables & URL Configuration
51
+
52
+ The SDK natively discovers official POD Platform URLs from your Next.js / Node.js `.env` configuration, or falls back to production defaults automatically:
53
+
54
+ ```env
55
+ # POD Platform Production URLs
56
+ NEXT_PUBLIC_ACCOUNTS_URL=https://accounts.pod.ir
57
+ NEXT_PUBLIC_API_POD_URL=https://api.pod.ir
58
+ NEXT_PUBLIC_API_POD_SANDBOX_URL=https://api.sandpod.ir
59
+ NEXT_PUBLIC_CMS_URL=https://api.pod.ir/srv/cms-server
60
+ NEXT_PUBLIC_IUMS_URL=https://indra.khatam.ac.ir/srv
61
+ NEXT_PUBLIC_NOTIFICATION_URL=https://api.pod.ir/srv/notification
62
+ NEXT_PUBLIC_PODREPORT_URL=https://reporting.pod.ir
63
+ NEXT_PUBLIC_PODSPACE_URL=https://podspace.pod.ir
64
+
65
+ # Sandbox Endpoints (Development & Staging)
66
+ NEXT_PUBLIC_SANDBOX_CMS_URL=http://api.sandpod.ir/srv/cms-sandbox
67
+ NEXT_PUBLIC_SANDBOX_CMS_CLIENT_URL=https://api.sandpod.ir/srv/cms-sandbox
68
+ NEXT_PUBLIC_SANDBOX_PODSPACE_URL=http://podspace.sandpod.ir
69
+ NEXT_PUBLIC_SANDBOX_STREAM_URL=https://sandbox-offline-stream.sandpod.ir
70
+ ```
71
+
72
+ > [!TIP]
73
+ > If you have defined these `NEXT_PUBLIC_*` variables in your `.env` or `.env.local`, `@rezamirzapour/pod-sdk` will automatically detect and apply them without any manual URL mapping.
74
+
75
+ ---
76
+
77
+ ## Quickstart
78
+
79
+ ### 1. Initialize the Client
80
+
81
+ ```typescript
82
+ // lib/pod.ts
83
+ import { createPodSdk, DEFAULT_POD_URLS, SANDBOX_POD_URLS } from '@rezamirzapour/pod-sdk';
84
+
85
+ export const podSdk = createPodSdk({
86
+ apiToken: process.env.POD_API_TOKEN,
87
+ clientId: process.env.POD_CLIENT_ID,
88
+ clientSecret: process.env.POD_CLIENT_SECRET,
89
+ privateKeyPem: process.env.POD_PRIVATE_KEY_PEM, // RSA Private Key for SSO OTP signing
90
+ revalidate: 3600, // Default ISR revalidation in seconds
91
+ // sandbox: true, // Optional: automatically routes to POD Sandbox endpoints
92
+ urls: {
93
+ // Optional programmatic overrides (defaults to official POD endpoints & env vars)
94
+ accounts: 'https://accounts.pod.ir',
95
+ apiPod: 'https://api.pod.ir',
96
+ cms: 'https://api.pod.ir/srv/cms-server',
97
+ iums: 'https://indra.khatam.ac.ir/srv',
98
+ notification: 'https://api.pod.ir/srv/notification',
99
+ podreport: 'https://reporting.pod.ir',
100
+ podspace: 'https://podspace.pod.ir',
101
+ podform: 'https://podform.pod.ir',
102
+ },
103
+ });
104
+ ```
105
+
106
+ ---
107
+
108
+ ## Deep Dive 1: CMS Content & Data Formatting
109
+
110
+ POD CMS stores dynamic fields inside a `metadata.content` array of `{ code, value, type }` objects. `@rezamirzapour/pod-sdk` automatically formats these into clean typed key-value pairs (`item.formatted`) and allows an optional custom `normalizer` function (`item.__normalized`).
111
+
112
+ > [!NOTE]
113
+ > `getContent2` has been removed. For authenticated/management content retrieval (using `Access-Token`), call `podSdk.cms.getAllContents()` or pass headers to `getContent()`.
114
+
115
+ ### 🏗️ Clean Architecture Pattern: Dedicated Data Access Layer (`NewsApi`)
116
+
117
+ In enterprise Next.js applications, avoid calling SDK clients directly inside UI components. Instead, encapsulate CMS queries, generics, normalizers, and caching logic inside a dedicated Data Access Layer (`services/newsApi.ts`).
118
+
119
+ #### Step 1: Create the Data Access Layer (`services/newsApi.ts`)
120
+
121
+ ```typescript
122
+ // services/newsApi.ts
123
+ import { podSdk } from '@/lib/pod';
124
+
125
+ // 1. Define the raw CMS Formatted Metadata schema (T)
126
+ export interface NewsArticleFormatted {
127
+ title: string;
128
+ coverImage: string; // Auto-transformed from hash to full Podspace CDN URL!
129
+ lead: string;
130
+ body: string;
131
+ author?: string;
132
+ }
133
+
134
+ // 2. Define computed / normalized domain attributes (P)
135
+ export interface NewsArticleNormalized {
136
+ summary: string;
137
+ readTime: number;
138
+ }
139
+
140
+ // 3. Define the Clean Domain Model consumed by your React Components
141
+ export interface NewsItem {
142
+ id: number;
143
+ entityId: number;
144
+ title: string;
145
+ coverImage: string;
146
+ summary: string;
147
+ body: string;
148
+ readTime: number;
149
+ publishedAt?: number;
150
+ }
151
+
152
+ export interface GetNewsListParams {
153
+ page?: number;
154
+ pageSize?: number;
155
+ tagTrees?: string[];
156
+ }
157
+
158
+ export class NewsApi {
159
+ private static readonly CONTENT_TYPE = 'company_news';
160
+
161
+ /**
162
+ * Fetches published news articles with Next.js App Router ISR Caching
163
+ */
164
+ public static async getNewsList({
165
+ page = 1,
166
+ pageSize = 10,
167
+ tagTrees,
168
+ }: GetNewsListParams = {}): Promise<{ items: NewsItem[]; total: number }> {
169
+ const offset = (page - 1) * pageSize;
170
+
171
+ // Call getContent with exact generics: <NewsArticleFormatted, NewsArticleNormalized>
172
+ const response = await podSdk.cms.getContent<NewsArticleFormatted, NewsArticleNormalized>(
173
+ {
174
+ contentTypeUniqueId: this.CONTENT_TYPE,
175
+ size: pageSize,
176
+ offset,
177
+ tagTrees,
178
+ },
179
+ {
180
+ // Custom normalizer executed during data formatting
181
+ normalizer: (item) => ({
182
+ summary: item.formatted.lead || item.formatted.title,
183
+ readTime: Math.max(1, Math.ceil((item.formatted.body?.length || 0) / 500)),
184
+ }),
185
+ revalidate: 1800, // Next.js ISR: cache for 30 minutes
186
+ }
187
+ );
188
+
189
+ if (response.hasError || !Array.isArray(response.result)) {
190
+ return { items: [], total: 0 };
191
+ }
192
+
193
+ const items: NewsItem[] = response.result.map((item) => ({
194
+ id: item.id,
195
+ entityId: item.entityId,
196
+ title: item.formatted.title,
197
+ coverImage: item.formatted.coverImage,
198
+ summary: item.__normalized.summary,
199
+ body: item.formatted.body,
200
+ readTime: item.__normalized.readTime,
201
+ publishedAt: item.timestamp,
202
+ }));
203
+
204
+ return {
205
+ items,
206
+ total: response.count || items.length,
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Fetches a single news article by its entity ID
212
+ */
213
+ public static async getNewsById(entityId: number): Promise<NewsItem | null> {
214
+ const response = await podSdk.cms.getContentByEntityId<NewsArticleFormatted, NewsArticleNormalized>({
215
+ entityId,
216
+ contentTypeUniqueId: this.CONTENT_TYPE,
217
+ });
218
+
219
+ const item = response.result?.[0];
220
+ if (!item) return null;
221
+
222
+ return {
223
+ id: item.id,
224
+ entityId: item.entityId,
225
+ title: item.formatted.title,
226
+ coverImage: item.formatted.coverImage,
227
+ summary: item.formatted.lead || item.formatted.title,
228
+ body: item.formatted.body,
229
+ readTime: Math.max(1, Math.ceil((item.formatted.body?.length || 0) / 500)),
230
+ publishedAt: item.timestamp,
231
+ };
232
+ }
233
+
234
+ /**
235
+ * Publishes a new article to CMS
236
+ */
237
+ public static async publishNews(data: {
238
+ title: string;
239
+ lead: string;
240
+ body: string;
241
+ imageHash: string;
242
+ }) {
243
+ return podSdk.cms.addContent({
244
+ contentTypeUniqueId: this.CONTENT_TYPE,
245
+ body: {
246
+ name: data.title,
247
+ fieldCode: ['title', 'lead', 'body', 'coverImage'],
248
+ fieldValue: [data.title, data.lead, data.body, data.imageHash],
249
+ canComment: true,
250
+ canLike: true,
251
+ enable: true,
252
+ isSearchable: true,
253
+ },
254
+ });
255
+ }
256
+ }
257
+ ```
258
+
259
+ #### Step 2: Consume `NewsApi` in Next.js Server Components (`app/news/page.tsx`)
260
+
261
+ ```tsx
262
+ // app/news/page.tsx (Server Component)
263
+ import { NewsApi } from '@/services/newsApi';
264
+ import Link from 'next/link';
265
+
266
+ interface NewsPageProps {
267
+ searchParams: { page?: string };
268
+ }
269
+
270
+ export default async function NewsPage({ searchParams }: NewsPageProps) {
271
+ const page = Number(searchParams.page) || 1;
272
+ const { items, total } = await NewsApi.getNewsList({ page, pageSize: 12 });
273
+
274
+ return (
275
+ <main className="container mx-auto py-8 px-4">
276
+ <h1 className="text-3xl font-bold mb-6">Company News</h1>
277
+
278
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
279
+ {items.map((news) => (
280
+ <article key={news.entityId} className="border rounded-xl overflow-hidden shadow-sm hover:shadow-md transition">
281
+ {news.coverImage && (
282
+ <img
283
+ src={news.coverImage}
284
+ alt={news.title}
285
+ className="w-full h-48 object-cover"
286
+ />
287
+ )}
288
+ <div className="p-4">
289
+ <h2 className="text-xl font-semibold mb-2">
290
+ <Link href={`/news/${news.entityId}`}>{news.title}</Link>
291
+ </h2>
292
+ <p className="text-gray-600 text-sm line-clamp-2">{news.summary}</p>
293
+ <div className="mt-4 flex items-center justify-between text-xs text-gray-400">
294
+ <span>{news.readTime} min read</span>
295
+ <Link href={`/news/${news.entityId}`} className="text-blue-600 font-medium hover:underline">
296
+ Read more →
297
+ </Link>
298
+ </div>
299
+ </div>
300
+ </article>
301
+ ))}
302
+ </div>
303
+ </main>
304
+ );
305
+ }
306
+ ```
307
+
308
+ ---
309
+
310
+ ## Deep Dive 2: CMS Product Module (RAD API Swagger)
311
+
312
+ The CMS Product module connects directly to POD RAD API endpoints ([Swagger documentation](https://rad-sandbox.sandpod.ir/api/documentation?tag=product)). It handles products with prices, discounts, barcodes, dynamic `metadata.product` field formatting, and batch management.
313
+
314
+ ### Accessing Products
315
+
316
+ You can access product methods either via `podSdk.product` or `podSdk.cms.products`:
317
+
318
+ ```typescript
319
+ // Both point to the same CmsProductService instance
320
+ podSdk.product.getProducts(...);
321
+ podSdk.cms.products.getProducts(...);
322
+ ```
323
+
324
+ ### 🛍️ Clean Architecture Pattern: Product Data Access Layer (`ProductsApi`)
325
+
326
+ ```typescript
327
+ // services/productsApi.ts
328
+ import { podSdk } from '@/lib/pod';
329
+
330
+ // 1. Raw formatted product metadata (T)
331
+ export interface LaptopFormatted {
332
+ cpu: string;
333
+ ram: string;
334
+ storage: string;
335
+ displayPhoto: string; // Auto-transformed from hash to Podspace CDN URL
336
+ }
337
+
338
+ // 2. Computed / normalized presentation fields (P)
339
+ export interface LaptopNormalized {
340
+ finalPrice: number;
341
+ discountBadge: string;
342
+ }
343
+
344
+ // 3. Clean Domain Product Model
345
+ export interface ProductItemModel {
346
+ entityId: number;
347
+ name: string;
348
+ price: number;
349
+ finalPrice: number;
350
+ discountBadge: string;
351
+ photoUrl: string;
352
+ specs: {
353
+ cpu: string;
354
+ ram: string;
355
+ storage: string;
356
+ };
357
+ }
358
+
359
+ export class ProductsApi {
360
+ private static readonly PRODUCT_TYPE = 'laptops';
361
+
362
+ /**
363
+ * Fetches published products with price & discount filters
364
+ */
365
+ public static async getProducts(params: {
366
+ fromPrice?: number;
367
+ toPrice?: number;
368
+ barcode?: string;
369
+ page?: number;
370
+ pageSize?: number;
371
+ } = {}): Promise<ProductItemModel[]> {
372
+ const { fromPrice, toPrice, barcode, page = 1, pageSize = 20 } = params;
373
+ const offset = (page - 1) * pageSize;
374
+
375
+ const response = await podSdk.product.getProducts<LaptopFormatted, LaptopNormalized>(
376
+ {
377
+ productTypeUniqueId: this.PRODUCT_TYPE,
378
+ fromPrice,
379
+ toPrice,
380
+ barcode,
381
+ size: pageSize,
382
+ offset,
383
+ },
384
+ {
385
+ normalizer: (item) => {
386
+ const discount = item.discount || 0;
387
+ const finalPrice = item.price ? item.price * (1 - discount / 100) : 0;
388
+ return {
389
+ finalPrice,
390
+ discountBadge: discount > 0 ? `${discount}% OFF` : '',
391
+ };
392
+ },
393
+ revalidate: 3600, // Next.js ISR: cache for 1 hour
394
+ }
395
+ );
396
+
397
+ if (response.hasError || !Array.isArray(response.result)) {
398
+ return [];
399
+ }
400
+
401
+ return response.result.map((item) => ({
402
+ entityId: item.entityId,
403
+ name: item.name || '',
404
+ price: item.price || 0,
405
+ finalPrice: item.__normalized.finalPrice,
406
+ discountBadge: item.__normalized.discountBadge,
407
+ photoUrl: item.formatted.displayPhoto,
408
+ specs: {
409
+ cpu: item.formatted.cpu,
410
+ ram: item.formatted.ram,
411
+ storage: item.formatted.storage,
412
+ },
413
+ }));
414
+ }
415
+
416
+ /**
417
+ * Retrieves single product by entityId
418
+ */
419
+ public static async getProductById(entityId: number) {
420
+ const res = await podSdk.product.getProductByEntityId<LaptopFormatted, LaptopNormalized>({
421
+ entityId,
422
+ productTypeUniqueId: this.PRODUCT_TYPE,
423
+ });
424
+ return res.result?.[0] || null;
425
+ }
426
+
427
+ /**
428
+ * Look up product by barcode scanner
429
+ */
430
+ public static async getProductByBarcode(barcode: string) {
431
+ return podSdk.product.getProductByBarcode<LaptopFormatted>(barcode);
432
+ }
433
+
434
+ /**
435
+ * Adds and publishes a new product
436
+ */
437
+ public static async createProduct(data: {
438
+ name: string;
439
+ price: number;
440
+ cpu: string;
441
+ ram: string;
442
+ storage: string;
443
+ photoHash: string;
444
+ }) {
445
+ return podSdk.product.addProduct({
446
+ productTypeUniqueId: this.PRODUCT_TYPE,
447
+ body: {
448
+ name: data.name,
449
+ enable: true,
450
+ fieldCode: ['cpu', 'ram', 'storage', 'displayPhoto', 'price'],
451
+ fieldValue: [data.cpu, data.ram, data.storage, data.photoHash, data.price],
452
+ },
453
+ });
454
+ }
455
+ }
456
+ ```
457
+
458
+ ### CMS Methods Reference
459
+
460
+ #### Content Methods (`podSdk.cms`)
461
+
462
+ | Method | HTTP | Path | Description |
463
+ | :--- | :--- | :--- | :--- |
464
+ | `getContent<T, P>(params, opts)` | `GET` | `/api/core/contents/enable` | Get published enabled contents with generic typing. |
465
+ | `getAllContents<T, P>(params, opts)` | `GET` | `/api/core/contents` | Get contents with managing credentials (`Access-Token`). |
466
+ | `getMyContents<T, P>(params, opts)` | `GET` | `/api/core/contents/my` | Get current user/client contents. |
467
+ | `getContentsByType<T, P>(typeId, params)` | `GET` | `/api/core/contents/{contentTypeUniqueId}` | Get contents by ContentType. |
468
+ | `getContentByEntityId<T, P>(params)` | `GET` | `/api/core/contents/enable/{entityId}` | Get single published content item. |
469
+ | `getContentByUniqueId<T, P>(uniqueId)` | `GET` | `/api/core/contents/{uniqueId}/byContentUniqueId/enable` | Get content by uniqueId. |
470
+ | `searchContent<T, P>(params)` | `GET` | `/api/core/contents/search` | Search published contents by keyword. |
471
+ | `addContent<T, P>(params)` | `POST` | `/api/core/contents/{contentTypeUniqueId}/add-publish` | Add and publish content item. |
472
+ | `createContent<T, P>(params)` | `POST` | `/api/core/contents/{contentTypeUniqueId}` | Create unpublished/draft content. |
473
+ | `editContent<T, P>(entityId, params)` | `POST` | `/api/core/contents/{typeId}/{entityId}/edit-publish` | Update and publish content item. |
474
+ | `updateContent<T, P>(entityId, params)` | `POST` | `/api/core/contents/{typeId}/{entityId}` | Update content item. |
475
+ | `patchContent<T, P>(entityId, params)` | `PATCH` | `/api/core/contents/{typeId}/{entityId}` | Partial update content. |
476
+ | `publishContent(typeId, entityId)` | `POST` | `/api/core/contents/{typeId}/publish/{entityId}` | Publish single content item. |
477
+ | `unpublishContent(typeId, entityId)` | `POST` | `/api/core/contents/{typeId}/unpublish/{entityId}` | Unpublish single content item. |
478
+ | `batchPublish(params)` | `PUT` | `/api/core/contents/{typeId}/publish` | Batch publish multiple contents. |
479
+ | `batchUnpublish(params)` | `PUT` | `/api/core/contents/{typeId}/unpublish` | Batch unpublish multiple contents. |
480
+ | `archiveContent(params)` | `POST` | `/api/core/contents/{typeId}/archive` | Archive content items. |
481
+ | `unarchiveContent(params)` | `POST` | `/api/core/contents/{typeId}/unarchive` | Unarchive content items. |
482
+ | `getArchivedContents(params)` | `GET` | `/api/core/contents/archive` | Get archived contents list. |
483
+ | `getDrafts(params)` | `GET` | `/api/core/contents/draft` | Get drafts list. |
484
+ | `createDraft(params)` | `POST` | `/api/core/contents/{typeId}/draft` | Create a draft. |
485
+ | `getComments(typeId, entityId)` | `GET` | `/api/core/contents/{typeId}/comments/{entityId}` | Get comments on content. |
486
+ | `getLikes(uniqueId, entityId)` | `GET` | `/api/core/contents/{uniqueId}/like/{entityId}` | Get likes of content item. |
487
+ | `getCategories(params)` | `GET` | `/api/core/tags/root/tree/enable` | Get category/tag tree. |
488
+ | `timelineSearch(query)` | `GET` | `/api/core/contents/ai/timeline-search/enable` | Advanced AI timeline search. |
489
+
490
+ #### Product Methods (`podSdk.product` / `podSdk.cms.products`)
491
+
492
+ | Method | HTTP | Path | Description |
493
+ | :--- | :--- | :--- | :--- |
494
+ | `getProducts<T, P>(params, opts)` | `GET` | `/api/core/products/enable` | Get enabled published products with price/discount filters. |
495
+ | `getAllProducts<T, P>(params, opts)` | `GET` | `/api/core/products` | Get products with provider credentials (`Access-Token`). |
496
+ | `getManageProducts<T, P>(params, opts)`| `GET` | `/api/core/products/manage` | Get products for manager view. |
497
+ | `getProductsByType<T, P>(typeId, params)`| `GET` | `/api/core/products/{productTypeUniqueId}` | Get products by ProductType. |
498
+ | `getProductByEntityId<T, P>(params)` | `GET` | `/api/core/products/enable/{entityId}` | Get single product by entityId. |
499
+ | `getProductByUniqueId<T, P>(uniqueId)` | `GET` | `/api/core/products/{uniqueId}/byProductUniqueId/enable` | Get product by uniqueId. |
500
+ | `getProductByBarcode<T, P>(barcode)` | `GET` | `/api/core/products/barcode/enable` | Lookup products matching a barcode. |
501
+ | `searchProducts<T, P>(params)` | `GET` | `/api/core/products/enable/search` | Search published products. |
502
+ | `addProduct<T, P>(params)` | `POST` | `/api/core/products/{productTypeUniqueId}/add-publish` | Create and publish new product. |
503
+ | `createProduct<T, P>(params)` | `POST` | `/api/core/products/{productTypeUniqueId}` | Create draft/unpublished product. |
504
+ | `editProduct<T, P>(entityId, params)` | `POST` | `/api/core/products/{typeId}/{entityId}/edit-publish` | Edit and publish existing product. |
505
+ | `updateProduct<T, P>(entityId, params)` | `POST` | `/api/core/products/{typeId}/{entityId}` | Update existing product. |
506
+ | `patchProduct<T, P>(entityId, params)` | `PATCH` | `/api/core/products/{typeId}/{entityId}` | Partial update product. |
507
+ | `publishProduct(typeId, entityId)` | `POST` | `/api/core/products/{typeId}/publish/{entityId}` | Publish product. |
508
+ | `unpublishProduct(typeId, entityId)` | `POST` | `/api/core/products/{typeId}/unpublish/{entityId}` | Unpublish product. |
509
+ | `batchPublish(params)` | `PUT` | `/api/core/products/publish` | Batch publish multiple products. |
510
+ | `batchUnpublish(params)` | `PUT` | `/api/core/products/unpublish` | Batch unpublish multiple products. |
511
+ | `archiveProduct(params)` | `POST` | `/api/core/products/archive` | Archive products. |
512
+ | `unarchiveProduct(params)` | `POST` | `/api/core/products/unarchive` | Unarchive products. |
513
+ | `getArchivedProducts(params)` | `GET` | `/api/core/products/archive` | Get archived products list. |
514
+ | `timelineSearch(query)` | `GET` | `/api/core/products/ai/timeline-search/enable` | AI timeline search on products. |
515
+
516
+ ---
517
+
518
+ ## Deep Dive 3: CMS Tags & Tag Categories (RAD API Swagger: tag=tags)
519
+
520
+ Manage tag categories and multi-level hierarchical tag trees ([Swagger documentation](https://rad-sandbox.sandpod.ir/api/documentation?tag=tags)).
521
+
522
+ ### Accessing Tag Services
523
+
524
+ Access via `podSdk.tags` or `podSdk.cms.tags`:
525
+
526
+ ```typescript
527
+ // Category Management (with Access-Token)
528
+ const categories = await podSdk.tags.getTagCategories({ size: 20 });
529
+ await podSdk.tags.createTagCategory({ name: 'Technology', desc: 'Tech articles' });
530
+ await podSdk.tags.publishTagCategory(categoryId);
531
+
532
+ // Public Hierarchical Tag Tree (without token, defaults to 'root')
533
+ const rootTree = await podSdk.tags.getTagTree('root', { levelCount: 3 });
534
+
535
+ // Backward-compatible shortcut
536
+ const categoriesTree = await podSdk.cms.getCategories({ levelCount: 3 });
537
+
538
+ // Tree Node Management (create, update parent, code, ancestors)
539
+ await podSdk.tags.createTagTreeItem('technology', {
540
+ name: 'Next.js',
541
+ code: 'NEXTJS',
542
+ parentId: 10,
543
+ });
544
+ await podSdk.tags.updateTagTreeParent('technology', nodeId, newParentId);
545
+ await podSdk.tags.updateTagTreeCode('technology', nodeId, 'NEW_CODE');
546
+ const ancestors = await podSdk.tags.getTagTreeAncestors('technology', nodeId);
547
+ ```
548
+
549
+ ### Tag Methods Reference (`podSdk.tags` / `podSdk.cms.tags`)
550
+
551
+ | Method | HTTP | Path | Description |
552
+ | :--- | :--- | :--- | :--- |
553
+ | `getTagCategories(params)` | `GET` | `/api/core/tags/category` | List tag categories (manage). |
554
+ | `createTagCategory(params)` | `POST` | `/api/core/tags/category` | Create new tag category. |
555
+ | `getTagCategory(id)` | `GET` | `/api/core/tags/category/{id}` | Show single tag category. |
556
+ | `updateTagCategory(id, params)` | `PUT` | `/api/core/tags/category/{id}` | Update tag category. |
557
+ | `publishTagCategory(id)` | `PUT` | `/api/core/tags/category/{id}/publish` | Publish tag category. |
558
+ | `unpublishTagCategory(id)` | `PUT` | `/api/core/tags/category/{id}/unpublish` | Unpublish tag category. |
559
+ | `getTagTree(categoryId, params)` | `GET` | `/api/core/tags/{categoryId}/tree/enable` | Get enabled tag tree (public). |
560
+ | `getManageTagTree(categoryId, params)` | `GET` | `/api/core/tags/{categoryId}/tree/manage` | Get tag tree (manage with token). |
561
+ | `createTagTreeItem(categoryId, params)` | `POST` | `/api/core/tags/{categoryId}/tree` | Create tag tree node for category. |
562
+ | `getTagTreeItem(categoryId, id)` | `GET` | `/api/core/tags/{categoryId}/tree/{id}/enable` | Show enabled tag tree node. |
563
+ | `getManageTagTreeItem(categoryId, id)` | `GET` | `/api/core/tags/{categoryId}/tree/{id}/manage` | Show tag tree node (manage). |
564
+ | `updateTagTreeItem(categoryId, id, params)` | `PUT` | `/api/core/tags/{categoryId}/tree/{id}` | Update tag tree node. |
565
+ | `publishTagTreeItem(categoryId, id)` | `PUT` | `/api/core/tags/{categoryId}/tree/publish/{id}` | Publish tag tree node. |
566
+ | `unpublishTagTreeItem(categoryId, id)` | `PUT` | `/api/core/tags/{categoryId}/tree/unpublish/{id}` | Unpublish tag tree node. |
567
+ | `updateTagTreeParent(categoryId, id, parentId)` | `PUT` | `/api/core/tags/{categoryId}/tree/parent/{id}` | Update parent of tag tree node. |
568
+ | `getTagTreeAncestors(categoryId, id, params)` | `GET` | `/api/core/tags/{categoryId}/tree/parent/{id}/enable` | Get node ancestors (public). |
569
+ | `getManageTagTreeAncestors(categoryId, id, params)`| `GET` | `/api/core/tags/{categoryId}/tree/parent/{id}/manage` | Get node ancestors (manage). |
570
+ | `updateTagTreeCode(categoryId, id, code)` | `PUT` | `/api/core/tags/{categoryId}/tree/code/{id}` | Update code of tag tree node. |
571
+
572
+ ---
573
+
574
+ ## Deep Dive 4: CustomPost & Generic Typed Repositories
575
+
576
+ `CustomPost` allows persisting arbitrary JSON schemas on the POD Platform. `@rezamirzapour/pod-sdk` provides two complementary approaches:
577
+
578
+ 1. **Direct Service Method Calls with Generics (`searchTimelineByMetadata<T>`, `getCustomPost<T>`, `addCustomPost<T>`)**
579
+ 2. **High-Level Typed CRUD Repository (`CustomPostCrudService<DataType>`)**
580
+
581
+ ```typescript
582
+ // lib/blog.ts
583
+ import { podSdk } from '@/lib/pod';
584
+
585
+ export interface BlogPostMeta {
586
+ slug: string;
587
+ title: string;
588
+ summary: string;
589
+ views: number;
590
+ }
591
+
592
+ // Search posts with typed metadata query
593
+ export async function getPublishedPosts() {
594
+ const response = await podSdk.customPost.searchTimelineByMetadata<BlogPostMeta>({
595
+ entityName: 'blog_post',
596
+ metadata: { views: 100 },
597
+ size: 20,
598
+ offset: 0,
599
+ });
600
+
601
+ return response.result.map((r) => r.item.metadata);
602
+ }
603
+ ```
604
+
605
+ ---
606
+
607
+ ## Other POD Microservices
608
+
609
+ ### 4. POD SSO (OTP Handshake with Web Crypto RSA Signature)
610
+
611
+ ```typescript
612
+ // app/actions/auth.ts
613
+ 'use server';
614
+
615
+ import { podSdk } from '@/lib/pod';
616
+
617
+ // Step 1: Handshake and send OTP
618
+ export async function sendOtp(phoneNumber: string, clientIp: string) {
619
+ const handshake = await podSdk.sso.handshake(`web-${Date.now()}`, clientIp);
620
+ if (handshake.hasError || !handshake.result?.keyId) {
621
+ throw new Error(handshake.message || 'SSO Handshake failed');
622
+ }
623
+
624
+ // Automatic RSA-SHA256 signature using Web Crypto API
625
+ const otpRes = await podSdk.sso.sendOtpCode(handshake.result.keyId, phoneNumber);
626
+ return {
627
+ success: !otpRes.hasError,
628
+ authorization: otpRes.authorization,
629
+ };
630
+ }
631
+
632
+ // Step 2: Verify OTP code & exchange for tokens
633
+ export async function verifyOtp(authorization: string, phoneNumber: string, code: string) {
634
+ const verify = await podSdk.sso.verifyOtpCode(authorization, phoneNumber, code);
635
+ if (verify.hasError || !verify.result?.code) {
636
+ throw new Error(verify.message || 'OTP verification failed');
637
+ }
638
+
639
+ const tokens = await podSdk.sso.generateToken(verify.result.code);
640
+ return tokens.result; // { access_token, refresh_token, expires_in }
641
+ }
642
+
643
+ // Step 3: Fetch profile with access token
644
+ export async function getProfile(accessToken: string) {
645
+ const profile = await podSdk.sso.getUserProfile(accessToken);
646
+ return profile.result;
647
+ }
648
+ ```
649
+
650
+ ---
651
+
652
+ ### 5. Podspace (File Uploads & CDN URLs)
653
+
654
+ ```typescript
655
+ // app/actions/upload.ts
656
+ 'use server';
657
+
658
+ import { podSdk } from '@/lib/pod';
659
+
660
+ export async function uploadFile(formData: FormData) {
661
+ const res = await podSdk.podspace.uploadFile(formData, '/uploads', true);
662
+ if (res.hasError) {
663
+ throw new Error(res.message || 'Upload failed');
664
+ }
665
+
666
+ const publicUrl = podSdk.podspace.getFileUrl(res.result.hash, true);
667
+ return { url: publicUrl, hash: res.result.hash };
668
+ }
669
+ ```
670
+
671
+ ---
672
+
673
+ ### 6. Notification (SMS Delivery)
674
+
675
+ ```typescript
676
+ // app/actions/notify.ts
677
+ 'use server';
678
+
679
+ import { podSdk } from '@/lib/pod';
680
+
681
+ export async function sendSms(phoneNumber: string, text: string) {
682
+ return podSdk.notification.sendSms({
683
+ receptor: phoneNumber,
684
+ message: text,
685
+ });
686
+ }
687
+ ```
688
+
689
+ ---
690
+
691
+ ### 7. Social (Comments & Likes)
692
+
693
+ ```typescript
694
+ // app/actions/social.ts
695
+ 'use server';
696
+
697
+ import { podSdk } from '@/lib/pod';
698
+
699
+ export async function addComment(postId: number, content: string) {
700
+ return podSdk.social.addComment({
701
+ postId,
702
+ text: content,
703
+ });
704
+ }
705
+
706
+ export async function likePost(postId: number) {
707
+ return podSdk.social.likePost(postId);
708
+ }
709
+ ```
710
+
711
+ ---
712
+
713
+ ### 8. IUMS (Identity & University Management Service)
714
+
715
+ ```typescript
716
+ // app/actions/iums.ts
717
+ 'use server';
718
+
719
+ import { podSdk } from '@/lib/pod';
720
+
721
+ export async function getStudentByNationalCode(nationalCode: string) {
722
+ return podSdk.iums.getStudentInformationByNationalCode(nationalCode);
723
+ }
724
+ ```
725
+
726
+ ---
727
+
728
+ ## Environment Variables Reference
729
+
730
+ ```env
731
+ # .env.local
732
+ POD_API_TOKEN=your_api_token_here
733
+ POD_CLIENT_ID=your_client_id_here
734
+ POD_CLIENT_SECRET=your_client_secret_here
735
+ POD_PRIVATE_KEY_PEM="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
736
+ ```
737
+
738
+ ---
739
+
740
+ ## License
741
+
742
+ MIT © [Reza](https://github.com/rezamirzapour2)